diff --git a/.cargo/config.toml b/.cargo/config.toml index 97a998251..bd2bffffc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -24,10 +24,12 @@ rustflags = [ [env] # Trim bundled-SQLite subsystems the workspace never touches. FTS5 stays: -# chat-store's `search` feature builds its index on it. Shared cache stays: -# sqlite-storage's in-memory mode shares one DB across the pool via -# `cache=shared` URIs. JSON and extension loading are unused by -# diesel/sqlite-storage/chat-store (checked: no json_* SQL, no +# nothing here builds an index on it today, but it is the one subsystem an +# embedder is likely to want from this crate's bundled SQLite, and this file +# is not inherited by consumers — so keeping it is what proves it still +# compiles. Shared cache stays: sqlite-storage's in-memory mode shares one DB +# across the pool via `cache=shared` URIs. JSON and extension loading are +# unused by diesel/sqlite-storage (checked: no json_* SQL, no # load_extension callers); dropping loadable extensions also removes a # dlopen surface. STAT4/DBSTAT/RTREE/FTS3/SOUNDEX are upstream-default-off # features libsqlite3-sys turns on; none are exercised here. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bd1764f44..c362a3360 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -474,7 +474,7 @@ jobs: run: > cargo check --no-default-features --verbose -p whatsapp-rust -p wacore-derive -p wacore-libsignal -p wacore-noise -p waproto - -p whatsapp-rust-ureq-http-client -p whatsapp-rust-chat-store + -p whatsapp-rust-ureq-http-client -p whatsapp-rust-sqlite-storage -p whatsapp-rust-tokio-transport - name: Publish test timings if: always() diff --git a/Cargo.lock b/Cargo.lock index 39d5b3991..4994aea9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4257,26 +4257,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "whatsapp-rust-chat-store" -version = "0.1.0" -dependencies = [ - "buffa", - "chrono", - "diesel", - "diesel_migrations", - "flate2", - "log", - "portable-atomic", - "serde_json", - "thiserror 2.0.19", - "tokio", - "wacore", - "wacore-binary", - "waproto", - "whatsapp-rust-sqlite-storage", -] - [[package]] name = "whatsapp-rust-plugin-metrics" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9022ef596..3f2b4a380 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ members = [ "plugins/metrics", "plugins/wam", "plugins/wam-catalog", - "storages/chat-store", "storages/sqlite-storage", "tests/bench-integration", "tests/e2e", diff --git a/storages/chat-store/Cargo.toml b/storages/chat-store/Cargo.toml deleted file mode 100644 index 83d008a3b..000000000 --- a/storages/chat-store/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -[package] -name = "whatsapp-rust-chat-store" -version = "0.1.0" -edition = "2024" -rust-version.workspace = true -authors = ["João Lucas "] -license = "MIT" -repository = "https://github.com/jlucaso1/whatsapp-rust" -description = "SQLite-backed chat/message history store for whatsapp-rust" -# Held back from crates.io: the schema and the query surface are still moving, -# and publishing pins both. Drop this line to release it alongside the rest. -publish = false - -[features] -default = [] -# Full-text message search via SQLite FTS5 (virtual table + sync triggers, -# created lazily at open; requires an FTS5-enabled SQLite, which the bundled -# build of whatsapp-rust-sqlite-storage provides). -search = [] - -[dependencies] -buffa = { workspace = true } -# `now` implies `std` and skips the local-timezone backends; only `Utc` is used. -chrono = { workspace = true, features = ["now"] } -diesel = { workspace = true } -diesel_migrations = { workspace = true } -log = { workspace = true } -serde_json = { workspace = true, features = ["std"] } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["sync", "rt", "macros"] } -wacore = { workspace = true } -wacore-binary = { workspace = true } -waproto = { workspace = true } -whatsapp-rust-sqlite-storage = { path = "../sqlite-storage", version = "0.7.0" } - -[dev-dependencies] -flate2 = { workspace = true } -portable-atomic = { workspace = true } -tokio = { workspace = true, features = [ - "sync", - "rt", - "rt-multi-thread", - "time", - "macros", -] } - -[lints] -workspace = true diff --git a/storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/down.sql b/storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/down.sql deleted file mode 100644 index 5776a3450..000000000 --- a/storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/down.sql +++ /dev/null @@ -1,6 +0,0 @@ -DROP TABLE IF EXISTS media_refs; -DROP TABLE IF EXISTS message_receipts; -DROP TABLE IF EXISTS contacts; -DROP TABLE IF EXISTS reactions; -DROP TABLE IF EXISTS messages; -DROP TABLE IF EXISTS chats; diff --git a/storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/up.sql b/storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/up.sql deleted file mode 100644 index 230d1a7c9..000000000 --- a/storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/up.sql +++ /dev/null @@ -1,96 +0,0 @@ --- Chat/message history tables. They live in the SAME database file as the --- whatsapp-rust-sqlite-storage tables (shared pool via SqliteStore::shared()); --- these table names are reserved by the chat-store crate. - -CREATE TABLE chats ( - device_id INTEGER NOT NULL, - jid TEXT NOT NULL, - name TEXT, - last_message_ts BIGINT NOT NULL DEFAULT 0, - last_message_preview TEXT, - last_message_kind TEXT, - -- -1 = manually marked unread (WA Web convention) - unread_count INTEGER NOT NULL DEFAULT 0, - pinned_at BIGINT, - muted_until BIGINT, - archived BOOLEAN NOT NULL DEFAULT FALSE, - ephemeral_expiration INTEGER, - -- Monotonic self-read state: everything at or below the watermark is - -- read, plus the JSON id list for boundary-instant/keyed coverage that a - -- scalar watermark cannot express. A delayed/stale read event must - -- neither re-inflate nor re-clear the unread badge. - read_boundary_ms BIGINT NOT NULL DEFAULT 0, - read_boundary_ids TEXT, - PRIMARY KEY (device_id, jid) -); - -CREATE INDEX idx_chats_order ON chats (device_id, archived, last_message_ts DESC); - -CREATE TABLE messages ( - device_id INTEGER NOT NULL, - chat_jid TEXT NOT NULL, - msg_id TEXT NOT NULL, - sender_jid TEXT NOT NULL, - from_me BOOLEAN NOT NULL DEFAULT FALSE, - timestamp_ms BIGINT NOT NULL, - kind TEXT NOT NULL, - text_content TEXT, - -- wa::Message encoded with buffa; NULL for tombstones (revoked) and - -- undecryptable placeholders. Source of truth: columns are projections. - proto BLOB, - -- WebMessageInfo.Status values: 0 error, 1 pending, 2 server ack, - -- 3 delivered, 4 read, 5 played. - status INTEGER NOT NULL DEFAULT 1, - starred BOOLEAN NOT NULL DEFAULT FALSE, - edited_at_ms BIGINT, - revoked BOOLEAN NOT NULL DEFAULT FALSE, - PRIMARY KEY (device_id, chat_jid, msg_id) -); - -CREATE INDEX idx_messages_chat_time ON messages (device_id, chat_jid, timestamp_ms DESC, msg_id DESC); --- Server acks carry only the message id, not the chat. -CREATE INDEX idx_messages_by_id ON messages (device_id, msg_id); - -CREATE TABLE reactions ( - device_id INTEGER NOT NULL, - chat_jid TEXT NOT NULL, - msg_id TEXT NOT NULL, - sender_jid TEXT NOT NULL, - emoji TEXT NOT NULL, - ts_ms BIGINT NOT NULL, - PRIMARY KEY (device_id, chat_jid, msg_id, sender_jid) -); - -CREATE TABLE contacts ( - device_id INTEGER NOT NULL, - jid TEXT NOT NULL, - push_name TEXT, - full_name TEXT, - first_name TEXT, - business_name TEXT, - PRIMARY KEY (device_id, jid) -); - --- Per-user delivery/read receipts (group read-by lists). -CREATE TABLE message_receipts ( - device_id INTEGER NOT NULL, - chat_jid TEXT NOT NULL, - msg_id TEXT NOT NULL, - user_jid TEXT NOT NULL, - -- Same scale as messages.status: 3 delivered, 4 read, 5 played. - receipt_type INTEGER NOT NULL, - ts_ms BIGINT NOT NULL, - PRIMARY KEY (device_id, chat_jid, msg_id, user_jid) -); - --- Downloaded-media cache index: content hash -> local file, so media survives --- restarts and identical files are stored once. -CREATE TABLE media_refs ( - device_id INTEGER NOT NULL, - file_sha256 BLOB NOT NULL, - file_path TEXT NOT NULL, - mime_type TEXT, - size_bytes BIGINT, - downloaded_at_ms BIGINT NOT NULL, - PRIMARY KEY (device_id, file_sha256) -); diff --git a/storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/down.sql b/storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/down.sql deleted file mode 100644 index 3217bd064..000000000 --- a/storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/down.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Irreversible: the device that keyed each folded row is not recorded --- anywhere, and the rows it produced were unreachable artifacts. Nothing to --- restore. diff --git a/storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/up.sql b/storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/up.sql deleted file mode 100644 index 619c85f18..000000000 --- a/storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/up.sql +++ /dev/null @@ -1,70 +0,0 @@ --- Fold rows written under a device-suffixed JID (`user:48@lid`) onto the bare --- identity every lookup uses. --- --- Receipts were the one event that reached the store with the peer's wire --- identity intact, so a companion device's traffic was filed under keys --- nothing reads: phantom chat rows, one read-by row per device instead of per --- participant, and contact names the bare lookup never finds. The writers --- normalize now; this heals what they left behind. A chat, contact or --- participant key never legitimately carries a device, so `LIKE '%:%@%'` --- selects exactly those artifacts. - --- Phantom chats. Only a receipt could key a chat by device and messages always --- landed on the bare thread, so these are empty; the guard keeps a row that --- somehow owns messages rather than orphaning them. -DELETE FROM chats - WHERE jid LIKE '%:%@%' - AND NOT EXISTS ( - SELECT 1 FROM messages - WHERE messages.device_id = chats.device_id - AND messages.chat_jid = chats.jid); - --- Contacts. An existing bare row wins (a live path wrote it with the same or --- newer data); otherwise the device-keyed names carry over to it. Two devices --- of the same peer can disagree, so the newest row is inserted first and the --- rest lose to it — OR IGNORE resolves in SELECT order, which makes the pick --- deterministic instead of scan-dependent. -INSERT OR IGNORE INTO contacts (device_id, jid, push_name, full_name, first_name, business_name) -SELECT device_id, - substr(jid, 1, instr(jid, ':') - 1) || substr(jid, instr(jid, '@')), - push_name, - full_name, - first_name, - business_name - FROM contacts - WHERE jid LIKE '%:%@%' - ORDER BY rowid DESC; - -DELETE FROM contacts - WHERE jid LIKE '%:%@%'; - --- Read-by rows. Highest receipt type per participant wins, the live path's --- monotonic rule: drop every row a sibling of the same bare identity beats, --- then rename what survives (UPDATE OR IGNORE leaves same-type ties behind, --- which the final DELETE clears). -DELETE FROM message_receipts - WHERE EXISTS ( - SELECT 1 FROM message_receipts s - WHERE s.device_id = message_receipts.device_id - AND s.chat_jid = message_receipts.chat_jid - AND s.msg_id = message_receipts.msg_id - AND s.receipt_type > message_receipts.receipt_type - AND CASE - WHEN instr(s.user_jid, ':') > 0 - THEN substr(s.user_jid, 1, instr(s.user_jid, ':') - 1) - || substr(s.user_jid, instr(s.user_jid, '@')) - ELSE s.user_jid - END = CASE - WHEN instr(message_receipts.user_jid, ':') > 0 - THEN substr(message_receipts.user_jid, 1, instr(message_receipts.user_jid, ':') - 1) - || substr(message_receipts.user_jid, instr(message_receipts.user_jid, '@')) - ELSE message_receipts.user_jid - END); - -UPDATE OR IGNORE message_receipts - SET user_jid = substr(user_jid, 1, instr(user_jid, ':') - 1) - || substr(user_jid, instr(user_jid, '@')) - WHERE user_jid LIKE '%:%@%'; - -DELETE FROM message_receipts - WHERE user_jid LIKE '%:%@%'; diff --git a/storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/down.sql b/storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/down.sql deleted file mode 100644 index b69644352..000000000 --- a/storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS idx_messages_chat_time; -CREATE INDEX idx_messages_chat_time ON messages (device_id, chat_jid, timestamp_ms DESC, msg_id DESC); diff --git a/storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/up.sql b/storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/up.sql deleted file mode 100644 index e73cd7f27..000000000 --- a/storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/up.sql +++ /dev/null @@ -1,29 +0,0 @@ --- Order same-second messages by arrival, not by id. --- --- The server's `t` is whole seconds, so every `timestamp_ms` ends in `000` and --- two messages exchanged inside the same second are byte-identical on the sort --- key. The tiebreak then fell through to `msg_id`, which encodes nothing about --- time and is biased: this library stamps a constant `3EB0` prefix on every id --- it generates, while a peer's ids are effectively uniform hex, so descending --- id order put the inbound message on top for ~75% of ties — a reply rendering --- above the message it answers, every time. --- --- The tiebreak is `rowid` now: the order the socket delivered the rows, which --- is the question the tiebreak was always asking. Realign the index to match. --- An ASC index scanned in reverse yields `timestamp_ms DESC, rowid DESC` --- directly (index keys carry the rowid as their trailing column), so the new --- sort streams from the index with no temp B-tree — the DESC form could not, --- because a forward scan of it gives `rowid ASC` within a tie. --- --- That holds for a single chat key. A 1:1 thread addressed by both of a peer's --- identities queries `chat_jid IN (pn, lid)`, which SQLite runs as two index --- ranges and merge-sorts, so an aliased chat still pays a temp B-tree until --- the pair is reconciled onto one key. --- --- On VACUUM: it may renumber rowids, but it rewrites the table in rowid order, --- so the RELATIVE order this sort depends on survives. What does not is a --- `MessageCursor` held across one — cursors are meant for a live paging --- session, not for persisting. (The FTS index maps by rowid too and carries --- the same caveat; see `fts.rs`.) -DROP INDEX IF EXISTS idx_messages_chat_time; -CREATE INDEX idx_messages_chat_time ON messages (device_id, chat_jid, timestamp_ms); diff --git a/storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/down.sql b/storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/down.sql deleted file mode 100644 index fd5b5bbba..000000000 --- a/storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/down.sql +++ /dev/null @@ -1,3 +0,0 @@ -DROP INDEX IF EXISTS idx_chats_pinned; -DROP INDEX IF EXISTS idx_chats_order; -CREATE INDEX idx_chats_order ON chats (device_id, archived, last_message_ts DESC); diff --git a/storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/up.sql b/storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/up.sql deleted file mode 100644 index 99f6e77fc..000000000 --- a/storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/up.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Let the chat list stream from an index instead of sorting the whole table. --- --- `chats()` orders by `(pinned_at IS NULL, pinned_at DESC, last_message_ts --- DESC)`. SQLite cannot serve a leading `pinned_at IS NULL` expression from a --- plain column index, so that sort was a full scan of `chats` plus a temp --- B-tree on every call — including the calls that only wanted one page, or one --- chat. The reader splits it into a pinned pass and a non-pinned pass now, and --- these two indexes make each pass a pure ordered range scan. --- --- Both carry `jid` as the last key column: it completes the primary key, which --- gives the keyset cursor a unique, stable tiebreak at a page boundary. --- --- `archived` leaves the key and becomes a filter. As a leading column it split --- the activity order into two runs, so the archived-inclusive list had to sort --- them back together; as a filter both list modes stream from one index and --- stop at LIMIT. It costs a scan past archived chats when they are dense near --- the head, which is the cheaper side of the trade for a list that is almost --- always read from the top. -DROP INDEX IF EXISTS idx_chats_order; -CREATE INDEX idx_chats_order ON chats (device_id, last_message_ts DESC, jid DESC); - --- Partial: pinning is capped at a handful of chats, so this stays tiny and --- costs nothing on the writes that never touch `pinned_at`. --- --- `last_message_ts` sits between the two because pin times collide in --- practice: history sync carries them at second precision, so several chats --- can share one. Activity decides between equally-pinned chats, as it did --- under the old combined sort; `jid` only settles what activity cannot. -CREATE INDEX idx_chats_pinned ON chats (device_id, pinned_at DESC, last_message_ts DESC, jid DESC) - WHERE pinned_at IS NOT NULL; diff --git a/storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/down.sql b/storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/down.sql deleted file mode 100644 index b908579ae..000000000 --- a/storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/down.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Back to one row per user. Several states per user collapse to the furthest --- one, which is what the narrower key could represent; the earlier instants --- are dropped because there is nowhere to put them. -CREATE TABLE message_receipts_old ( - device_id INTEGER NOT NULL, - chat_jid TEXT NOT NULL, - msg_id TEXT NOT NULL, - user_jid TEXT NOT NULL, - receipt_type INTEGER NOT NULL, - ts_ms BIGINT NOT NULL, - PRIMARY KEY (device_id, chat_jid, msg_id, user_jid) -); - -INSERT INTO message_receipts_old - (device_id, chat_jid, msg_id, user_jid, receipt_type, ts_ms) -SELECT r.device_id, r.chat_jid, r.msg_id, r.user_jid, r.receipt_type, r.ts_ms -FROM message_receipts r -WHERE r.receipt_type = ( - SELECT MAX(s.receipt_type) FROM message_receipts s - WHERE s.device_id = r.device_id AND s.chat_jid = r.chat_jid - AND s.msg_id = r.msg_id AND s.user_jid = r.user_jid -); - -DROP TABLE message_receipts; - -ALTER TABLE message_receipts_old RENAME TO message_receipts; diff --git a/storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/up.sql b/storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/up.sql deleted file mode 100644 index cf25e594b..000000000 --- a/storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/up.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Keep one receipt row per state, not one per user that overwrites itself. --- --- The old key ended at `user_jid`, so a user's row advanced in place: the --- `read` receipt overwrote the `delivered` one and took its `ts_ms` with it. --- That is enough to render a group's "read by" list, which only ever asks for --- each member's furthest state, and it is why the narrower key held up until --- 1:1 message info needed something else. --- --- WA Web models a message's receipts as three separate collections --- (`.delivery`, `.read`, `.played`), each entry carrying its own `t`, and its --- 1:1 info drawer reads all three at once to show "Delivered hh:mm" above --- "Read hh:mm". Both of those instants have to survive for that to render, so --- the state joins the key and each transition keeps its own row. --- --- Existing rows are already unique on the narrower key, so they stay unique --- under the wider one and carry over untouched — each user keeping the single --- furthest state recorded so far, with earlier states simply absent rather --- than wrong. -CREATE TABLE message_receipts_new ( - device_id INTEGER NOT NULL, - chat_jid TEXT NOT NULL, - msg_id TEXT NOT NULL, - user_jid TEXT NOT NULL, - -- Same scale as messages.status: 3 delivered, 4 read, 5 played. - receipt_type INTEGER NOT NULL, - ts_ms BIGINT NOT NULL, - PRIMARY KEY (device_id, chat_jid, msg_id, user_jid, receipt_type) -); - -INSERT INTO message_receipts_new - (device_id, chat_jid, msg_id, user_jid, receipt_type, ts_ms) -SELECT device_id, chat_jid, msg_id, user_jid, receipt_type, ts_ms -FROM message_receipts; - -DROP TABLE message_receipts; - -ALTER TABLE message_receipts_new RENAME TO message_receipts; diff --git a/storages/chat-store/src/error.rs b/storages/chat-store/src/error.rs deleted file mode 100644 index d84d7cac6..000000000 --- a/storages/chat-store/src/error.rs +++ /dev/null @@ -1,24 +0,0 @@ -use thiserror::Error; -use wacore::store::error::StoreError; - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ChatStoreError { - #[error("storage error")] - Store(#[from] StoreError), - - #[error("invalid full-text search query")] - InvalidSearchQuery, - - /// A writer batch rolled back; the writes acknowledged by this `flush` - /// were dropped. Carries the underlying error rendered to text (one batch - /// outcome fans out to many flush waiters). - #[error("write batch failed: {0}")] - WriteBatchFailed(String), -} - -pub type Result = std::result::Result; - -pub(crate) fn db_err(e: diesel::result::Error) -> StoreError { - StoreError::Database(Box::new(e)) -} diff --git a/storages/chat-store/src/fts.rs b/storages/chat-store/src/fts.rs deleted file mode 100644 index e73648c2f..000000000 --- a/storages/chat-store/src/fts.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! Full-text message search via SQLite FTS5 (feature `search`). -//! -//! External-content index over `messages.text_content`, kept in sync by -//! triggers so the writer never has to think about it. Created lazily and -//! idempotently at open instead of in a migration, so builds without the -//! feature leave no FTS objects behind. -//! -//! Caveat: the index maps by implicit rowid; after a manual `VACUUM`, run -//! `INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`. - -use diesel::prelude::*; -use wacore_binary::Jid; - -use crate::error::{ChatStoreError, Result, db_err}; -use crate::queries::MessageRow; -use crate::schema; -use crate::store::ChatStore; -use crate::types::StoredMessage; - -pub(crate) fn ensure_fts(conn: &mut SqliteConnection) -> QueryResult<()> { - // One transaction for existence-check + DDL + backfill: a partially - // created index (table committed, rebuild lost) would pass the existence - // gate forever after, leaving pre-existing rows permanently unindexed. - conn.transaction(ensure_fts_inner) -} - -fn ensure_fts_inner(conn: &mut SqliteConnection) -> QueryResult<()> { - #[derive(QueryableByName)] - struct CountRow { - #[diesel(sql_type = diesel::sql_types::Integer)] - n: i32, - } - let already_exists: bool = diesel::sql_query( - "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'messages_fts'", - ) - .get_result::(conn)? - .n > 0; - - // Canonical FTS5 external-content recipe: EVERY content row gets exactly - // one index entry (NULL indexes as empty), and the update trigger pairs - // delete-then-insert in one body. Anything cuter (WHEN guards, split - // triggers) breaks the symmetry FTS5's shadow bookkeeping relies on and - // corrupts rank queries. - for statement in [ - "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( - text_content, content='messages', content_rowid='rowid')", - "CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN - INSERT INTO messages_fts(rowid, text_content) VALUES (new.rowid, new.text_content); - END", - "CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, text_content) - VALUES ('delete', old.rowid, old.text_content); - END", - "CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE OF text_content ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, text_content) - VALUES ('delete', old.rowid, old.text_content); - INSERT INTO messages_fts(rowid, text_content) VALUES (new.rowid, new.text_content); - END", - ] { - diesel::sql_query(statement).execute(conn)?; - } - // First enablement on a database that already has messages: the triggers - // only cover future writes, so index the existing rows once. - if !already_exists { - diesel::sql_query("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')") - .execute(conn)?; - } - Ok(()) -} - -/// Turn free text into an FTS5 query: each whitespace token becomes a quoted -/// prefix term (`"tok"*`), AND-combined. Sidesteps FTS5 operator syntax so -/// user input can't produce a syntax error. -fn build_match_query(input: &str) -> Option { - let mut query = String::with_capacity(input.len() + 8); - for token in input.split_whitespace() { - if !query.is_empty() { - query.push(' '); - } - query.push('"'); - for ch in token.chars() { - if ch == '"' { - query.push('"'); - } - query.push(ch); - } - query.push_str("\"*"); - } - (!query.is_empty()).then_some(query) -} - -/// Shortest token that still earns relevance ranking. -/// -/// `ORDER BY rank` has to score every row a term matches before `LIMIT` can -/// discard any, so its cost tracks the size of the match set rather than the -/// page the caller asked for. A one- or two-character prefix matches a large -/// fraction of a real store, which is how a single keystroke turned into a -/// multi-second query. Below this length the search orders by arrival instead: -/// FTS5 walks its index in rowid order and stops at `LIMIT`, and "the newest -/// things that start with this" is a defensible answer for a term that short. -const MIN_RANKED_TERM_LEN: usize = 3; - -/// Max rowids per hydration statement, under SQLite's default 999 -/// host-parameter limit. -const ID_PARAM_CHUNK: usize = 900; - -#[derive(QueryableByName)] -struct FtsHit { - #[diesel(sql_type = diesel::sql_types::BigInt)] - rowid: i64, -} - -impl ChatStore { - /// Full-text search over message text/captions, best match first. The - /// query is plain words (prefix-matched); FTS5 operators are neutralized. - /// - /// A term shorter than three characters is ordered newest-first instead of - /// by relevance: ranking has to score every row such a prefix matches - /// before `limit` can discard any, which on a real store means most of it. - pub async fn search_messages(&self, query: &str, limit: i64) -> Result> { - self.search(query, None, limit).await - } - - /// The same search restricted to one chat. - /// - /// Scoping happens inside the FTS join, so a chat that ranks sparsely still - /// yields its hits — over-fetching globally and filtering afterwards both - /// costs more and silently drops them. - /// - /// A 1:1 chat may be addressed by either of the peer's identities; both - /// find the thread. - pub async fn search_messages_in_chat( - &self, - chat: &Jid, - query: &str, - limit: i64, - ) -> Result> { - self.search(query, Some(chat.to_string()), limit).await - } - - async fn search( - &self, - query: &str, - chat: Option, - limit: i64, - ) -> Result> { - let Some(match_query) = build_match_query(query) else { - return Err(ChatStoreError::InvalidSearchQuery); - }; - let ranked = query - .split_whitespace() - .all(|token| token.chars().count() >= MIN_RANKED_TERM_LEN); - // A negative LIMIT means "unbounded" to SQLite; never let that happen. - let limit = limit.max(0); - if limit == 0 { - return Ok(Vec::new()); - } - let device_id = self.device_id(); - let rows: Vec = - self.db() - .read(move |conn| { - let keys = match &chat { - Some(chat) => crate::lid::chat_key_candidates(conn, device_id, chat) - .map_err(db_err)?, - None => Vec::new(), - }; - let hits = fts_hits(conn, device_id, &match_query, &keys, ranked, limit)?; - if hits.is_empty() { - return Ok(Vec::new()); - } - // One statement per chunk of hits, instead of a point query - // per hit (each of which used to re-resolve the chat's - // identity keys first, so N hits cost ~2N serialized round - // trips). `limit` is the caller's, so the id list is chunked - // rather than trusted to stay under SQLite's host-parameter - // ceiling. - let ids: Vec = hits.iter().map(|hit| hit.rowid).collect(); - let mut rows: Vec = Vec::with_capacity(ids.len()); - for chunk in ids.chunks(ID_PARAM_CHUNK) { - rows.extend( - schema::messages::dsl::messages - .filter(schema::messages::dsl::rowid.eq_any(chunk)) - .load::(conn) - .map_err(db_err)?, - ); - } - // `eq_any` returns table order, and chunking splits it - // further; restore the order the ranking (or the recency - // scan) put them in. - let rank_of: std::collections::HashMap = - ids.iter().enumerate().map(|(at, id)| (*id, at)).collect(); - rows.sort_by_key(|row| rank_of.get(&row.rowid).copied().unwrap_or(usize::MAX)); - Ok(rows) - }) - .await?; - Ok(rows.into_iter().map(Into::into).collect()) - } -} - -/// Matching rowids, best first. `keys` scopes the search to one chat's storage -/// identities; empty searches every chat. -fn fts_hits( - conn: &mut SqliteConnection, - device_id: i32, - match_query: &str, - keys: &[String], - ranked: bool, - limit: i64, -) -> wacore::store::error::Result> { - use diesel::sql_types::{BigInt, Integer, Text}; - - // Constant fragments, never caller input. `rank` is qualified because it is - // only unambiguous today by accident — `messages` has no such column, and - // an unqualified reference would silently start resolving to it if one were - // ever added. - let order = if ranked { "f.rank" } else { "f.rowid DESC" }; - let Some(first_key) = keys.first() else { - return diesel::sql_query(format!( - "SELECT f.rowid AS rowid - FROM messages_fts f JOIN messages m ON m.rowid = f.rowid - WHERE messages_fts MATCH ? AND m.device_id = ? - ORDER BY {order} LIMIT ?" - )) - .bind::(match_query) - .bind::(device_id) - .bind::(limit) - .load(conn) - .map_err(db_err); - }; - // A chat has at most two storage identities (PN and LID). Padding the - // single-key case to two placeholders keeps one statically bound statement - // instead of a variadic one; `IN (x, x)` is `IN (x)`. - let second_key = keys.get(1).unwrap_or(first_key); - diesel::sql_query(format!( - "SELECT f.rowid AS rowid - FROM messages_fts f JOIN messages m ON m.rowid = f.rowid - WHERE messages_fts MATCH ? AND m.device_id = ? AND m.chat_jid IN (?, ?) - ORDER BY {order} LIMIT ?" - )) - .bind::(match_query) - .bind::(device_id) - .bind::(first_key) - .bind::(second_key) - .bind::(limit) - .load(conn) - .map_err(db_err) -} - -#[cfg(test)] -mod tests { - use super::build_match_query; - - #[test] - fn match_query_neutralizes_operators_and_quotes() { - assert_eq!(build_match_query("hello"), Some("\"hello\"*".into())); - assert_eq!( - build_match_query("hello world"), - Some("\"hello\"* \"world\"*".into()) - ); - assert_eq!(build_match_query("a\"b"), Some("\"a\"\"b\"*".into())); - assert_eq!( - build_match_query("NOT OR AND"), - Some("\"NOT\"* \"OR\"* \"AND\"*".into()) - ); - assert_eq!(build_match_query(" "), None); - } -} diff --git a/storages/chat-store/src/lib.rs b/storages/chat-store/src/lib.rs deleted file mode 100644 index e47df1ce3..000000000 --- a/storages/chat-store/src/lib.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! SQLite-backed chat/message history for whatsapp-rust. -//! -//! This crate materializes the client's event stream (messages, receipts, -//! edits, revokes, reactions, history sync, app-state chat updates) into -//! queryable tables in the SAME database file as the device store, so a UI or -//! stateful bot survives a restart without re-syncing. It has no UI -//! dependencies — it is a data layer any consumer can opt into. -//! -//! Design: -//! - **Event-sourced**: register [`ChatStore::handler`] on the client; a -//! single writer task applies batched events transactionally, in order. -//! - **Proto as source of truth**: each message row stores the encoded -//! `wa::Message` plus denormalized columns for listing/search — new proto -//! fields never require a migration. -//! - **Query + invalidation**: read via the async query API (keyset -//! pagination), subscribe to [`types::StoreChange`] to know when to re-query. -//! No second in-memory cache of rows. -//! - **Shared file, shared pool**: writes go through -//! [`SqliteStore::shared`](whatsapp_rust_sqlite_storage::SqliteStore), -//! so there is exactly one WAL writer per database file. -//! -//! ```ignore -//! let chat_store = ChatStore::new(&sqlite_store).await?; -//! let _chat_subscription = client.subscribe_handler(chat_store.handler()); -//! -//! let chats = chat_store.chats(false, 50).await?; -//! let page = chat_store.messages(&chats[0].jid, None, 40).await?; -//! let mut changes = chat_store.subscribe(); -//! ``` - -mod error; -#[cfg(feature = "search")] -mod fts; -mod lid; -mod materialize; -mod queries; -mod schema; -mod store; -pub mod types; - -pub use error::{ChatStoreError, Result}; -pub use store::ChatStore; -pub use types::{ - ArrivalCursor, ChatCursor, ChatEntry, ContactEntry, MediaRef, MessageCursor, MessageKind, - MessageStatus, ReactionEntry, ReceiptEntry, StoreChange, StoredMessage, -}; diff --git a/storages/chat-store/src/lid.rs b/storages/chat-store/src/lid.rs deleted file mode 100644 index dd9de7990..000000000 --- a/storages/chat-store/src/lid.rs +++ /dev/null @@ -1,396 +0,0 @@ -//! LID/PN peer-identity resolution for chat keys. -//! -//! A 1:1 peer has two interchangeable wire identities — phone number -//! (`@s.whatsapp.net`) and LID (`@lid`) — and traffic for one thread can -//! arrive under either, independent of which key its rows were stored under. -//! WA Web reconciles the two at lookup time -//! (`WAWebDBBulkGetRootMsgs.fixMsgKeysWithPnMapping`, -//! `WAWebLidMigrationUtils.getAlternateMsgKey`) and routes inbound 1:1 -//! traffic to the existing thread whichever identity addressed it -//! (`WAWebMessageProcessUtils.selectChatForOneOnOneMessage`): legacy chat ids -//! stay stable, only brand-new chats are keyed by LID. -//! -//! The device store's `lid_pn_mapping` table lives in the same database file -//! and is bidirectional, so both candidate keys of a peer are always -//! derivable — it already is the alias index WA Web keeps as the chat table's -//! `accountLid` column, and the chat-store needs no schema of its own. - -use diesel::prelude::*; -use diesel::sql_types::{BigInt, Binary, Bool, Integer, Nullable, Text}; -use wacore_binary::{Jid, Server}; - -use crate::schema; -use crate::store::ChangeSet; - -/// Bare 1:1 user chat key — the only namespace with a PN/LID alias. Hosted -/// and interop namespaces alias differently and are left alone. -/// -/// A device-suffixed input normalizes rather than being rejected: a peer's -/// companion device addresses traffic as `user:48@lid`, and every row of that -/// thread is keyed by the bare identity, so the device must not decide -/// whether a chat resolves. -fn user_chat(chat: &str) -> Option { - let jid: Jid = chat.parse().ok()?; - (jid.integrator == 0 && matches!(jid.server, Server::Pn | Server::Lid)) - .then(|| jid.into_non_ad()) -} - -#[derive(QueryableByName)] -struct UserRow { - #[diesel(sql_type = Text)] - user: String, -} - -/// The peer's other identity, from the device store's mapping table. PN -/// resolves to its most recently updated LID (the same rule as -/// `SqliteStore::get_pn_mapping`); LID resolves straight to its PN. -pub(crate) fn counterpart_chat_key( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, -) -> QueryResult> { - let Some(jid) = user_chat(chat) else { - return Ok(None); - }; - counterpart_of(conn, device_id, &jid) -} - -/// [`counterpart_chat_key`] for an already-normalized key, so callers that -/// need the normalized form themselves don't parse twice. -fn counterpart_of( - conn: &mut SqliteConnection, - device_id: i32, - jid: &Jid, -) -> QueryResult> { - let (sql, server) = if jid.is_lid() { - ( - "SELECT phone_number AS user FROM lid_pn_mapping \ - WHERE lid = ? AND device_id = ? LIMIT 1", - Server::Pn, - ) - } else { - ( - // The lid tiebreak keeps routing stable when updated_at ties — - // flapping between counterpart keys would re-split the thread. - "SELECT lid AS user FROM lid_pn_mapping \ - WHERE phone_number = ? AND device_id = ? \ - ORDER BY updated_at DESC, lid DESC LIMIT 1", - Server::Lid, - ) - }; - let row: Option = diesel::sql_query(sql) - .bind::(jid.user.as_str()) - .bind::(device_id) - .get_result(conn) - .optional()?; - Ok(row.map(|r| Jid::new(r.user, server).to_string())) -} - -/// Every key the peer's rows may live under: the given key plus its mapped -/// counterpart. Read queries filter with these so either identity finds the -/// thread (and a not-yet-merged split reads as one thread). -pub(crate) fn chat_key_candidates( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, -) -> QueryResult> { - let Some(jid) = user_chat(chat) else { - return Ok(vec![chat.to_string()]); - }; - let mut keys = vec![jid.to_string()]; - if let Some(alt) = counterpart_of(conn, device_id, &jid)? { - keys.push(alt); - } - Ok(keys) -} - -/// Storage key for a chat addressed as `wire_chat`, WA Web -/// `selectChatForOneOnOneMessage` parity: an existing thread keeps its key -/// whichever identity addressed it; a brand-new chat with a known LID is -/// keyed by the LID. Rows split across both keys (the state receipts dropped -/// under the wrong identity leave behind) are merged before routing. A -/// device-suffixed input is normalized even when no counterpart is known, so -/// a companion device can never materialize a thread of its own. -pub(crate) fn route_chat_key( - conn: &mut SqliteConnection, - device_id: i32, - wire_chat: &str, - cs: &mut ChangeSet, -) -> QueryResult { - let Some(jid) = user_chat(wire_chat) else { - return Ok(wire_chat.to_string()); - }; - let key = jid.to_string(); - let Some(alt) = counterpart_of(conn, device_id, &jid)? else { - return Ok(key); - }; - let existing: Vec = { - use schema::chats::dsl; - dsl::chats - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::jid.eq_any([key.as_str(), alt.as_str()])), - ) - .select(dsl::jid) - .load(conn)? - }; - match (existing.contains(&key), existing.contains(&alt)) { - (true, true) => merge_split_chat(conn, device_id, &key, &alt, cs), - (true, false) => Ok(key), - (false, true) => Ok(alt), - (false, false) => Ok(lid_side(&key, &alt).to_string()), - } -} - -fn lid_side<'a>(a: &'a str, b: &'a str) -> &'a str { - if a.ends_with("@lid") { a } else { b } -} - -fn newest_message_ts( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, -) -> QueryResult> { - use schema::messages::dsl; - dsl::messages - .filter(dsl::device_id.eq(device_id).and(dsl::chat_jid.eq(chat))) - .order((dsl::timestamp_ms.desc(), dsl::rowid.desc())) - .select(dsl::timestamp_ms) - .first(conn) - .optional() -} - -#[derive(QueryableByName)] -struct DupMessage { - #[diesel(sql_type = Text)] - id: String, - #[diesel(sql_type = Integer)] - status: i32, - #[diesel(sql_type = Bool)] - starred: bool, - #[diesel(sql_type = Nullable)] - edited_at_ms: Option, - #[diesel(sql_type = Bool)] - revoked: bool, - #[diesel(sql_type = Nullable)] - text_content: Option, - #[diesel(sql_type = Text)] - kind: String, - #[diesel(sql_type = Nullable)] - proto: Option>, -} - -/// Fold a peer's split PN/LID pair into one thread and return the surviving -/// key. Destination is the side with the newer message activity — that is the -/// thread the peer is living in — with ties (and the empty/empty case) going -/// to the LID side, the canonical identity going forward. Idempotent: with -/// nothing under the source key this is a no-op. -pub(crate) fn merge_split_chat( - conn: &mut SqliteConnection, - device_id: i32, - a: &str, - b: &str, - cs: &mut ChangeSet, -) -> QueryResult { - if a == b { - return Ok(a.to_string()); - } - let ts_a = newest_message_ts(conn, device_id, a)?; - let ts_b = newest_message_ts(conn, device_id, b)?; - let (src, dest) = match (ts_a, ts_b) { - (Some(ta), Some(tb)) if ta > tb => (b, a), - (Some(ta), Some(tb)) if ta < tb => (a, b), - (Some(_), None) => (b, a), - (None, Some(_)) => (a, b), - _ => { - let dest = lid_side(a, b); - if dest == a { (b, a) } else { (a, b) } - } - }; - let src_has_chat_row = { - use schema::chats::dsl; - dsl::chats - .filter(dsl::device_id.eq(device_id).and(dsl::jid.eq(src))) - .select(dsl::jid) - .first::(conn) - .optional()? - .is_some() - }; - let src_ts = if src == a { ts_a } else { ts_b }; - // Nothing lives under the source key: already reconciled (or never split). - if !src_has_chat_row && src_ts.is_none() { - return Ok(dest.to_string()); - } - - // A message duplicated across the pair folds by the live-path precedence - // rules — anything less loses receipts, stars, tombstones or edits that - // reached only the losing side before the split healed. - let dups: Vec = diesel::sql_query( - "SELECT m.msg_id AS id, m.status AS status, m.starred AS starred, \ - m.edited_at_ms AS edited_at_ms, m.revoked AS revoked, \ - m.text_content AS text_content, m.kind AS kind, m.proto AS proto \ - FROM messages m \ - WHERE m.device_id = ? AND m.chat_jid = ? AND EXISTS \ - (SELECT 1 FROM messages d WHERE d.device_id = m.device_id \ - AND d.chat_jid = ? AND d.msg_id = m.msg_id)", - ) - .bind::(device_id) - .bind::(src) - .bind::(dest) - .load(conn)?; - for dup in &dups { - use schema::messages::dsl; - diesel::update( - crate::store::message_row(device_id, dest, &dup.id).filter(dsl::status.lt(dup.status)), - ) - .set(dsl::status.eq(dup.status)) - .execute(conn)?; - if dup.starred { - diesel::update(crate::store::message_row(device_id, dest, &dup.id)) - .set(dsl::starred.eq(true)) - .execute(conn)?; - } - if dup.revoked { - diesel::update(crate::store::message_row(device_id, dest, &dup.id)) - .set(( - dsl::revoked.eq(true), - dsl::text_content.eq(None::), - dsl::proto.eq(None::>), - )) - .execute(conn)?; - } else if let Some(edited) = dup.edited_at_ms { - diesel::update( - crate::store::message_row(device_id, dest, &dup.id) - .filter(dsl::revoked.eq(false)) - // Strictly newer: a tie may be two competing edits, and - // keeping the destination's copy is the deterministic pick. - .filter(dsl::edited_at_ms.is_null().or(dsl::edited_at_ms.lt(edited))), - ) - .set(( - dsl::text_content.eq(dup.text_content.as_deref()), - dsl::kind.eq(&dup.kind), - dsl::proto.eq(dup.proto.as_deref()), - dsl::edited_at_ms.eq(Some(edited)), - )) - .execute(conn)?; - } - } - // UPDATE OR IGNORE: PK collisions (the dups above) stay behind and are - // dropped after. rowids survive the UPDATE, so the FTS external-content - // index stays consistent; the leftover DELETE fires its cleanup trigger. - diesel::sql_query( - "UPDATE OR IGNORE messages SET chat_jid = ? WHERE device_id = ? AND chat_jid = ?", - ) - .bind::(dest) - .bind::(device_id) - .bind::(src) - .execute(conn)?; - diesel::sql_query("DELETE FROM messages WHERE device_id = ? AND chat_jid = ?") - .bind::(device_id) - .bind::(src) - .execute(conn)?; - - // Satellites: the newest reaction per (msg, sender) and the highest - // receipt per (msg, user) win across the pair, matching their live-path - // monotonic rules — drop the losing destination rows, then move. - diesel::sql_query( - "DELETE FROM reactions WHERE device_id = ?1 AND chat_jid = ?3 AND EXISTS \ - (SELECT 1 FROM reactions s WHERE s.device_id = ?1 AND s.chat_jid = ?2 \ - AND s.msg_id = reactions.msg_id AND s.sender_jid = reactions.sender_jid \ - AND s.ts_ms > reactions.ts_ms)", - ) - .bind::(device_id) - .bind::(src) - .bind::(dest) - .execute(conn)?; - diesel::sql_query( - "UPDATE OR IGNORE reactions SET chat_jid = ? WHERE device_id = ? AND chat_jid = ?", - ) - .bind::(dest) - .bind::(device_id) - .bind::(src) - .execute(conn)?; - diesel::sql_query("DELETE FROM reactions WHERE device_id = ? AND chat_jid = ?") - .bind::(device_id) - .bind::(src) - .execute(conn)?; - - // Receipts, unlike reactions, get no "keep the furthest state" pass: they - // are keyed per state, so a side holding `read` and a side holding - // `delivered` are two facts about one message rather than two candidates - // for one row. What the merge has to settle instead is that both the chat - // key and the *peer's identity* are being unified at once — a 1:1's receipt - // names whoever the peer sent from, which is independent of the key the row - // was filed under, so one person can be spread across four combinations of - // (chat, user). Self receipts never reach here, so the peer is the only - // user a 1:1 row can name. - // - // Every statement below binds `?1` device_id, `?2` src, `?3` dest. - // - // Fold the instants first, over all four combinations at once. Doing it - // before anything is moved or renamed means the passes that follow are - // discarding exact duplicates rather than deciding between them: whichever - // row survives already carries the earliest time that state was reported. - // Neither identity is automatically the earlier one — the merge direction - // is chosen by chat activity, which says nothing about who saw it first. - diesel::sql_query( - "UPDATE message_receipts SET ts_ms = (SELECT MIN(s.ts_ms) FROM message_receipts s \ - WHERE s.device_id = message_receipts.device_id \ - AND s.chat_jid IN (?2, ?3) AND s.user_jid IN (?2, ?3) \ - AND s.msg_id = message_receipts.msg_id \ - AND s.receipt_type = message_receipts.receipt_type) \ - WHERE device_id = ?1 AND chat_jid IN (?2, ?3) AND user_jid IN (?2, ?3)", - ) - .bind::(device_id) - .bind::(src) - .bind::(dest) - .execute(conn)?; - - // Now the identity, on both sides: a receipt addressed to the surviving - // thread can still name the retiring one. - diesel::sql_query( - "UPDATE OR IGNORE message_receipts SET user_jid = ?3 \ - WHERE device_id = ?1 AND chat_jid IN (?2, ?3) AND user_jid = ?2", - ) - .bind::(device_id) - .bind::(src) - .bind::(dest) - .execute(conn)?; - // Past that rename, naming `src` is proof of a collision: the only rows - // still doing so are the ones `OR IGNORE` skipped because their renamed - // form already existed. Their instants are folded in, so every one of them - // is a pure duplicate — and both chat keys need sweeping, not just `dest`. - // A survivor under `src` would otherwise be carried to `dest` intact by the - // chat rename below, and one under `dest` is beyond that rename's reach - // entirely. Either way it outlives the merge still naming the retired - // identity: one peer read back as two users, the exact failure this - // reconciliation exists to prevent. - diesel::sql_query( - "DELETE FROM message_receipts \ - WHERE device_id = ?1 AND chat_jid IN (?2, ?3) AND user_jid = ?2", - ) - .bind::(device_id) - .bind::(src) - .bind::(dest) - .execute(conn)?; - - diesel::sql_query( - "UPDATE OR IGNORE message_receipts SET chat_jid = ?3 WHERE device_id = ?1 AND chat_jid = ?2", - ) - .bind::(device_id) - .bind::(src) - .bind::(dest) - .execute(conn)?; - diesel::sql_query("DELETE FROM message_receipts WHERE device_id = ?1 AND chat_jid = ?2") - .bind::(device_id) - .bind::(src) - .execute(conn)?; - - crate::store::merge_chat_metadata(conn, device_id, src, dest)?; - - cs.chats = true; - cs.message_chats.insert(src.to_string()); - cs.message_chats.insert(dest.to_string()); - Ok(dest.to_string()) -} diff --git a/storages/chat-store/src/materialize.rs b/storages/chat-store/src/materialize.rs deleted file mode 100644 index 4379275d9..000000000 --- a/storages/chat-store/src/materialize.rs +++ /dev/null @@ -1,626 +0,0 @@ -//! Pure event-to-row transforms: what a `wa::Message` means for the tables. -//! No I/O here so every rule is unit-testable without a database. - -use wacore::proto_helpers::MessageExt; -use wacore::types::events::UnavailableType; -use waproto::whatsapp as wa; - -use crate::types::MessageKind; - -/// What the writer should do with one inbound message. -#[derive(Debug)] -pub(crate) enum MessageOp { - /// Regular content: insert (or refresh) a message row. - Store { - kind: &'static str, - text: Option, - }, - /// A reaction to another message. `emoji` empty means "remove my reaction". - Reaction { target_id: String, emoji: String }, - /// An edit of another message: replace its content in place. - Edit { - target_id: String, - new_text: Option, - new_kind: &'static str, - new_proto: Vec, - }, - /// A revoke of another message: tombstone it. `target_from_me`/`target_participant` - /// come from the revoke KEY (they identify the target message's owner, not - /// the revoker — an admin revoke is authored by someone else), used when - /// the tombstone has to be created before its content arrives. - Revoke { - target_id: String, - target_from_me: bool, - target_participant: Option, - }, - /// Protocol/bookkeeping payloads that don't belong in a chat log. - Ignore, -} - -/// Content class of the unwrapped message, as a database label (the typed -/// read-model view is [`MessageKind`](crate::types::MessageKind)). Coarser -/// than the proto (one label per renderable bubble type), finer than WA's -/// stanza `type` attribute (which collapses everything to text/media). -pub(crate) fn message_kind(base: &wa::Message) -> &'static str { - if base.conversation.is_some() || base.extended_text_message.is_set() { - "text" - } else if base.image_message.is_set() { - "image" - } else if base.ptv_message.is_set() { - "ptv" - } else if base.video_message.is_set() { - "video" - } else if let Some(audio) = base.audio_message.as_option() { - if audio.ptt.unwrap_or(false) { - "ptt" - } else { - "audio" - } - } else if base.sticker_message.is_set() || base.lottie_sticker_message.is_set() { - "sticker" - } else if base.document_message.is_set() { - "document" - } else if base.contact_message.is_set() || base.contacts_array_message.is_set() { - "contact" - } else if base.location_message.is_set() || base.live_location_message.is_set() { - "location" - } else if base.poll_creation_message.is_set() - || base.poll_creation_message_v2.is_set() - || base.poll_creation_message_v3.is_set() - { - "poll" - } else if base.event_message.is_set() { - "event" - } else if base.group_invite_message.is_set() { - "group_invite" - } else if base.template_message.is_set() { - "template" - } else if base.template_button_reply_message.is_set() { - "template_reply" - } else if base.buttons_message.is_set() { - "buttons" - } else if base.buttons_response_message.is_set() { - "buttons_response" - } else if base.list_message.is_set() { - "list" - } else if base.list_response_message.is_set() { - "list_response" - } else if base.interactive_message.is_set() { - "interactive" - } else if base.interactive_response_message.is_set() { - "interactive_response" - } else { - "unknown" - } -} - -/// Kind label for a placeholder row of a message we could not decrypt. -pub(crate) const KIND_UNDECRYPTABLE: &str = "undecryptable"; - -/// Kind label for an `` fanout, or `None` when the failure is the -/// ordinary kind a retry can still resolve. -/// -/// The three unrecoverable subtypes are content the phone will never hand to a -/// companion, so their rows are permanent by design — a frontend has to render -/// them as their own thing (WA Web's one-time chip, for view-once) rather than -/// as "waiting for this message", and it can only do that if the store keeps -/// the distinction the wire made. -/// -/// Deliberately re-stated here instead of forwarding `UnavailableType::as_str`: -/// that string is the wire value and belongs to the protocol, while these are -/// on-disk labels that must not move when a wire attribute is renamed. -pub(crate) fn unavailable_kind(unavailable_type: UnavailableType) -> Option<&'static str> { - match unavailable_type { - UnavailableType::ViewOnce => Some(MessageKind::ViewOnce.as_str()), - UnavailableType::Hosted => Some(MessageKind::Hosted.as_str()), - UnavailableType::Bot => Some(MessageKind::Bot.as_str()), - // A plain fanout stays recoverable; PDO may still fill it in. - UnavailableType::Unknown => None, - } -} - -/// Classify one decrypted message into its materialization op. -pub(crate) fn classify(msg: &wa::Message) -> MessageOp { - let base = msg.get_base_message(); - - if let Some(reaction) = base.reaction_message.as_option() { - let Some(target_id) = reaction.key.as_option().and_then(|k| k.id.clone()) else { - return MessageOp::Ignore; - }; - return MessageOp::Reaction { - target_id, - emoji: reaction.text.clone().unwrap_or_default(), - }; - } - - if let Some(pm) = base.protocol_message.as_option() { - use wa::message::protocol_message::Type as ProtocolType; - let target_id = pm.key.as_option().and_then(|k| k.id.clone()); - match (pm.r#type, target_id) { - (Some(ProtocolType::REVOKE), Some(target_id)) => { - let key = pm.key.as_option(); - return MessageOp::Revoke { - target_id, - target_from_me: key.and_then(|k| k.from_me).unwrap_or(false), - target_participant: key.and_then(|k| k.participant.clone()), - }; - } - (Some(ProtocolType::MESSAGE_EDIT), Some(target_id)) => { - if let Some(edited) = pm.edited_message.as_option() { - let edited_base = edited.get_base_message(); - return MessageOp::Edit { - target_id, - new_text: extract_text(edited_base), - new_kind: message_kind(edited_base), - new_proto: waproto::codec::message_to_vec(edited), - }; - } - return MessageOp::Ignore; - } - // Key shares, history-sync notifications, peer data requests, ... - _ => return MessageOp::Ignore, - } - } - - let kind = message_kind(base); - if kind == "unknown" && !has_any_content(base) { - // Bare senderKeyDistribution / messageContextInfo carriers. - return MessageOp::Ignore; - } - MessageOp::Store { - kind, - text: extract_text(base), - } -} - -/// Text projection for list previews and full-text search: body text or media -/// caption. -pub(crate) fn extract_text(base: &wa::Message) -> Option { - base.text_content() - .or_else(|| base.get_caption()) - .or_else(|| business_text(base)) - .map(str::to_owned) -} - -/// Body text of the business content carriers. Extraction mirrors WA Web's -/// per-type parsers; footers/buttons stay display-side (readable from the proto). -fn business_text(base: &wa::Message) -> Option<&str> { - if let Some(tpl) = base.template_message.as_option() { - use wa::message::template_message::Format; - // WA Web reads hydratedTemplate ?? format's hydratedFourRowTemplate. - return tpl - .hydrated_template - .as_option() - .or_else(|| match tpl.format.as_ref() { - Some(Format::HydratedFourRowTemplate(t)) => Some(t.as_ref()), - _ => None, - }) - .and_then(|t| t.hydrated_content_text.as_deref()); - } - if let Some(reply) = base.template_button_reply_message.as_option() { - return reply.selected_display_text.as_deref(); - } - if let Some(buttons) = base.buttons_message.as_option() { - return buttons.content_text.as_deref(); - } - if let Some(resp) = base.buttons_response_message.as_option() { - use wa::message::buttons_response_message::Response; - return match resp.response.as_ref() { - Some(Response::SelectedDisplayText(text)) => Some(text.as_str()), - None => None, - }; - } - if let Some(list) = base.list_message.as_option() { - return list.description.as_deref(); - } - if let Some(resp) = base.list_response_message.as_option() { - return resp.title.as_deref(); - } - if let Some(interactive) = base.interactive_message.as_option() { - return interactive.body.as_option().and_then(|b| b.text.as_deref()); - } - if let Some(resp) = base.interactive_response_message.as_option() { - return resp.body.as_option().and_then(|b| b.text.as_deref()); - } - None -} - -/// Whether the unwrapped message carries anything a chat log should show. -/// Guards against storing rows for pure-bookkeeping payloads that classify as -/// "unknown". Decided structurally: strip the bookkeeping carriers and check -/// whether ANY other field remains set, so an unclassified-but-real bubble -/// (e.g. a future WA message type) that also carries `message_context_info` -/// is still stored. -fn has_any_content(base: &wa::Message) -> bool { - let mut probe = base.clone(); - probe.sender_key_distribution_message = Default::default(); - probe.fast_ratchet_key_sender_key_distribution_message = Default::default(); - probe.message_context_info = Default::default(); - // Encoded emptiness, not PartialEq: presence of an empty submessage still - // costs wire bytes, while buffa's equality folds it into "absent". - waproto::codec::message_encoded_len(&probe) > 0 -} - -#[cfg(test)] -mod tests { - use super::*; - use buffa::MessageField; - use wacore::proto_helpers::MessageBuilderExt; - - fn key_for(id: &str) -> MessageField { - MessageField::some(wa::MessageKey { - id: Some(id.into()), - ..Default::default() - }) - } - - #[test] - fn classifies_plain_text() { - let msg = wa::Message::text("hello"); - match classify(&msg) { - MessageOp::Store { kind, text } => { - assert_eq!(kind, "text"); - assert_eq!(text.as_deref(), Some("hello")); - } - other => panic!("expected Store, got {other:?}"), - } - } - - #[test] - fn classifies_ephemeral_wrapped_text() { - let msg = wa::Message { - ephemeral_message: MessageField::some(wa::message::FutureProofMessage { - message: MessageField::from_box(Box::new(wa::Message::text("secret"))), - }), - ..Default::default() - }; - match classify(&msg) { - MessageOp::Store { kind, text } => { - assert_eq!(kind, "text"); - assert_eq!(text.as_deref(), Some("secret")); - } - other => panic!("expected Store, got {other:?}"), - } - } - - fn store_result(msg: &wa::Message) -> (&'static str, Option) { - match classify(msg) { - MessageOp::Store { kind, text } => (kind, text), - other => panic!("expected Store, got {other:?}"), - } - } - - #[test] - fn classifies_hydrated_template_via_field() { - let msg = wa::Message { - template_message: MessageField::some(wa::message::TemplateMessage { - hydrated_template: MessageField::some( - wa::message::template_message::HydratedFourRowTemplate { - hydrated_content_text: Some("Dear customer, your bill is ready".into()), - hydrated_footer_text: Some("footer stays display-side".into()), - ..Default::default() - }, - ), - ..Default::default() - }), - ..Default::default() - }; - let (kind, text) = store_result(&msg); - assert_eq!(kind, "template"); - assert_eq!(text.as_deref(), Some("Dear customer, your bill is ready")); - } - - #[test] - fn classifies_hydrated_template_via_format_oneof() { - use wa::message::template_message::Format; - let msg = wa::Message { - template_message: MessageField::some(wa::message::TemplateMessage { - format: Some(Format::HydratedFourRowTemplate(Box::new( - wa::message::template_message::HydratedFourRowTemplate { - hydrated_content_text: Some("Your OTP is 000000".into()), - ..Default::default() - }, - ))), - ..Default::default() - }), - ..Default::default() - }; - let (kind, text) = store_result(&msg); - assert_eq!(kind, "template"); - assert_eq!(text.as_deref(), Some("Your OTP is 000000")); - } - - /// Non-hydrated template (placeholders not filled): stored as a template - /// row, just without extractable text. - #[test] - fn template_without_hydrated_content_stores_with_null_text() { - use wa::message::template_message::Format; - let msg = wa::Message { - template_message: MessageField::some(wa::message::TemplateMessage { - format: Some(Format::FourRowTemplate(Box::default())), - ..Default::default() - }), - ..Default::default() - }; - let (kind, text) = store_result(&msg); - assert_eq!(kind, "template"); - assert!(text.is_none()); - } - - #[test] - fn classifies_buttons_list_and_interactive_bodies() { - let buttons = wa::Message { - buttons_message: MessageField::some(wa::message::ButtonsMessage { - content_text: Some("Choose an option".into()), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - store_result(&buttons), - ("buttons", Some("Choose an option".to_owned())) - ); - - let list = wa::Message { - list_message: MessageField::some(wa::message::ListMessage { - description: Some("Pick a plan".into()), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - store_result(&list), - ("list", Some("Pick a plan".to_owned())) - ); - - let interactive = wa::Message { - interactive_message: MessageField::some(wa::message::InteractiveMessage { - body: MessageField::some(wa::message::interactive_message::Body { - text: Some("Confirm your order".into()), - }), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - store_result(&interactive), - ("interactive", Some("Confirm your order".to_owned())) - ); - } - - #[test] - fn classifies_business_response_messages() { - use wa::message::buttons_response_message::Response; - let buttons_resp = wa::Message { - buttons_response_message: MessageField::some(wa::message::ButtonsResponseMessage { - response: Some(Response::SelectedDisplayText("Yes, confirm".into())), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - store_result(&buttons_resp), - ("buttons_response", Some("Yes, confirm".to_owned())) - ); - - let list_resp = wa::Message { - list_response_message: MessageField::some(wa::message::ListResponseMessage { - title: Some("Basic plan".into()), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - store_result(&list_resp), - ("list_response", Some("Basic plan".to_owned())) - ); - - let tpl_reply = wa::Message { - template_button_reply_message: MessageField::some( - wa::message::TemplateButtonReplyMessage { - selected_display_text: Some("Track order".into()), - ..Default::default() - }, - ), - ..Default::default() - }; - assert_eq!( - store_result(&tpl_reply), - ("template_reply", Some("Track order".to_owned())) - ); - - let interactive_resp = wa::Message { - interactive_response_message: MessageField::some( - wa::message::InteractiveResponseMessage { - body: MessageField::some(wa::message::interactive_response_message::Body { - text: Some("flow reply".into()), - ..Default::default() - }), - ..Default::default() - }, - ), - ..Default::default() - }; - assert_eq!( - store_result(&interactive_resp), - ("interactive_response", Some("flow reply".to_owned())) - ); - } - - #[test] - fn classifies_image_with_caption() { - let msg = wa::Message { - image_message: MessageField::some(wa::message::ImageMessage { - caption: Some("look".into()), - ..Default::default() - }), - ..Default::default() - }; - match classify(&msg) { - MessageOp::Store { kind, text } => { - assert_eq!(kind, "image"); - assert_eq!(text.as_deref(), Some("look")); - } - other => panic!("expected Store, got {other:?}"), - } - } - - #[test] - fn ptt_and_audio_are_distinct() { - let ptt = wa::Message { - audio_message: MessageField::some(wa::message::AudioMessage { - ptt: Some(true), - ..Default::default() - }), - ..Default::default() - }; - let audio = wa::Message { - audio_message: MessageField::some(wa::message::AudioMessage::default()), - ..Default::default() - }; - assert_eq!(message_kind(&ptt), "ptt"); - assert_eq!(message_kind(&audio), "audio"); - } - - #[test] - fn classifies_reaction_add_and_remove() { - let add = wa::Message { - reaction_message: MessageField::some(wa::message::ReactionMessage { - key: key_for("MSG1"), - text: Some("👍".into()), - ..Default::default() - }), - ..Default::default() - }; - match classify(&add) { - MessageOp::Reaction { target_id, emoji } => { - assert_eq!(target_id, "MSG1"); - assert_eq!(emoji, "👍"); - } - other => panic!("expected Reaction, got {other:?}"), - } - - let remove = wa::Message { - reaction_message: MessageField::some(wa::message::ReactionMessage { - key: key_for("MSG1"), - text: Some(String::new()), - ..Default::default() - }), - ..Default::default() - }; - match classify(&remove) { - MessageOp::Reaction { emoji, .. } => assert!(emoji.is_empty()), - other => panic!("expected Reaction, got {other:?}"), - } - } - - #[test] - fn reaction_without_target_key_is_ignored() { - let msg = wa::Message { - reaction_message: MessageField::some(wa::message::ReactionMessage { - text: Some("👍".into()), - ..Default::default() - }), - ..Default::default() - }; - assert!(matches!(classify(&msg), MessageOp::Ignore)); - } - - #[test] - fn classifies_revoke_and_edit() { - use wa::message::protocol_message::Type as ProtocolType; - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: key_for("MSG2"), - r#type: Some(ProtocolType::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - assert!(matches!( - classify(&revoke), - MessageOp::Revoke { target_id, .. } if target_id == "MSG2" - )); - - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: key_for("MSG3"), - r#type: Some(ProtocolType::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("fixed"))), - ..Default::default() - }), - ..Default::default() - }; - match classify(&edit) { - MessageOp::Edit { - target_id, - new_text, - new_kind, - new_proto, - } => { - assert_eq!(target_id, "MSG3"); - assert_eq!(new_text.as_deref(), Some("fixed")); - assert_eq!(new_kind, "text"); - assert!(!new_proto.is_empty()); - } - other => panic!("expected Edit, got {other:?}"), - } - } - - #[test] - fn bookkeeping_only_messages_are_ignored() { - let skdm_only = wa::Message { - sender_key_distribution_message: MessageField::some( - wa::message::SenderKeyDistributionMessage::default(), - ), - ..Default::default() - }; - assert!(matches!(classify(&skdm_only), MessageOp::Ignore)); - - let key_share = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::APP_STATE_SYNC_KEY_SHARE), - ..Default::default() - }), - ..Default::default() - }; - assert!(matches!(classify(&key_share), MessageOp::Ignore)); - } - - #[test] - fn unknown_content_with_context_info_is_still_stored() { - // A future/unclassified bubble type often carries message_context_info - // (msg secrets); that must not demote it to bookkeeping-only. - let msg = wa::Message { - send_payment_message: MessageField::some(wa::message::SendPaymentMessage::default()), - message_context_info: MessageField::some(wa::MessageContextInfo::default()), - ..Default::default() - }; - match classify(&msg) { - MessageOp::Store { kind, .. } => assert_eq!(kind, "unknown"), - other => panic!("expected Store, got {other:?}"), - } - - // While a PURE bookkeeping payload still has no bubble. - let carrier_only = wa::Message { - sender_key_distribution_message: MessageField::some( - wa::message::SenderKeyDistributionMessage::default(), - ), - message_context_info: MessageField::some(wa::MessageContextInfo::default()), - ..Default::default() - }; - assert!(matches!(classify(&carrier_only), MessageOp::Ignore)); - } - - #[test] - fn unclassified_but_present_content_stores_as_unknown() { - let msg = wa::Message { - send_payment_message: MessageField::some(wa::message::SendPaymentMessage::default()), - ..Default::default() - }; - match classify(&msg) { - MessageOp::Store { kind, .. } => assert_eq!(kind, "unknown"), - other => panic!("expected Store, got {other:?}"), - } - } -} diff --git a/storages/chat-store/src/queries.rs b/storages/chat-store/src/queries.rs deleted file mode 100644 index 20948c5d6..000000000 --- a/storages/chat-store/src/queries.rs +++ /dev/null @@ -1,796 +0,0 @@ -//! Read API. Every query runs on the shared pool's blocking thread; results -//! come back as plain owned values (the SQLite page cache is the cache — no -//! row caching on this side). - -use std::str::FromStr; - -use chrono::{DateTime, Utc}; -use diesel::prelude::*; -use log::warn; -use wacore_binary::Jid; - -use crate::error::{Result, db_err}; -use crate::schema; -use crate::store::ChatStore; -use crate::types::{ - ArrivalCursor, ChatCursor, ChatEntry, ContactEntry, MediaRef, MessageCursor, MessageKind, - MessageStatus, ReactionEntry, ReceiptEntry, StoredMessage, -}; - -fn ms_to_utc(ms: i64) -> Option> { - DateTime::::from_timestamp_millis(ms) -} - -/// A wall-clock instant as the first whole millisecond at or after it. -/// -/// `timestamp_millis` truncates, and stored timestamps are whole milliseconds, -/// so a bound landing inside a millisecond has to move to the next one for both -/// ends of a half-open window: a row at `.500` is neither `>= .500_5` nor -/// excluded by `< .500_5`, and truncation gets both backwards. `Utc::now()` -/// carries nanoseconds, so this is the common case for a caller passing "an -/// hour ago", not an exotic one. -fn ceil_to_ms(t: DateTime) -> i64 { - let ms = t.timestamp_millis(); - if t.timestamp_subsec_nanos().is_multiple_of(1_000_000) { - ms - } else { - ms.saturating_add(1) - } -} - -type ContactRow = ( - String, - Option, - Option, - Option, - Option, -); - -type MediaRefRow = (Vec, String, Option, Option, i64); - -/// Parse a stored JID column; empty (own history messages with no participant) -/// maps to the default JID rather than an error. -fn parse_jid(raw: &str) -> Jid { - if raw.is_empty() { - return Jid::default(); - } - Jid::from_str(raw).unwrap_or_else(|_| { - warn!("chat-store: unparseable JID in database: {raw}"); - Jid::default() - }) -} - -#[derive(Queryable)] -struct ChatRow { - #[allow(dead_code)] - device_id: i32, - jid: String, - name: Option, - last_message_ts: i64, - last_message_preview: Option, - last_message_kind: Option, - unread_count: i32, - pinned_at: Option, - muted_until: Option, - archived: bool, - ephemeral_expiration: Option, - #[allow(dead_code)] - read_boundary_ms: i64, - #[allow(dead_code)] - read_boundary_ids: Option, -} - -impl From for ChatEntry { - fn from(row: ChatRow) -> Self { - ChatEntry { - jid: parse_jid(&row.jid), - name: row.name, - last_message_at: (row.last_message_ts > 0) - .then(|| ms_to_utc(row.last_message_ts)) - .flatten(), - last_message_preview: row.last_message_preview, - last_message_kind: row.last_message_kind.map(MessageKind::from_db), - unread_count: row.unread_count, - pinned_at: row.pinned_at.and_then(ms_to_utc), - // The writer stores i64::MAX for "muted forever"; that value is - // outside DateTime's range, and silently mapping it to None would - // make a forever-muted chat read as unmuted. - muted_until: row.muted_until.and_then(|ms| { - if ms == i64::MAX { - Some(DateTime::::MAX_UTC) - } else { - ms_to_utc(ms) - } - }), - archived: row.archived, - ephemeral_expiration: row.ephemeral_expiration.map(|e| e as u32), - } - } -} - -#[derive(Queryable)] -pub(crate) struct MessageRow { - #[allow(dead_code)] - device_id: i32, - chat_jid: String, - msg_id: String, - sender_jid: String, - from_me: bool, - timestamp_ms: i64, - kind: String, - text_content: Option, - proto: Option>, - status: i32, - starred: bool, - edited_at_ms: Option, - revoked: bool, - pub(crate) rowid: i64, -} - -impl From for StoredMessage { - fn from(row: MessageRow) -> Self { - let message = row.proto.as_deref().and_then(|bytes| { - match waproto::codec::message_decode(bytes) { - Ok(msg) => Some(Box::new(msg)), - Err(e) => { - // Denormalized columns still render; only the proto is lost. - warn!( - "chat-store: stored proto for {} undecodable: {e}", - row.msg_id - ); - None - } - } - }); - StoredMessage { - chat_jid: parse_jid(&row.chat_jid), - id: row.msg_id, - sender_jid: parse_jid(&row.sender_jid), - from_me: row.from_me, - timestamp: ms_to_utc(row.timestamp_ms).unwrap_or_default(), - kind: MessageKind::from_db(row.kind), - text: row.text_content, - message, - status: MessageStatus::from_raw(row.status), - starred: row.starred, - edited_at: row.edited_at_ms.and_then(ms_to_utc), - revoked: row.revoked, - seq: row.rowid, - } - } -} - -/// The session-wide arrival page, as a query. Split out so a test can pin its -/// plan: this read is only cheap while SQLite answers `ORDER BY rowid DESC` by -/// walking the table's own B-tree backwards, and nothing in the SQL says so. -fn arrival_page_query( - device_id: i32, - after: Option, - since_ms: Option, - until_ms: Option, - limit: i64, -) -> schema::messages::BoxedQuery<'static, diesel::sqlite::Sqlite> { - use diesel::sql_types::{Bool, Integer}; - use schema::messages::dsl; - // The unary `+` keeps `device_id` off the index the planner would otherwise - // reach for. `idx_messages_by_id` leads with `device_id`, so SQLite scores - // it as the better entry point and then pays a temp B-tree to put the whole - // device's messages back in rowid order — a full sort of the table on every - // page, to return one page. Denied that index, it reads the table backwards - // and stops at LIMIT, which is the plan this feed is designed around. - let mut query = dsl::messages - .filter(diesel::dsl::sql::("+device_id = ").bind::(device_id)) - .into_boxed(); - if let Some(cursor) = after { - query = query.filter(dsl::rowid.lt(cursor.seq)); - } - // Wall-clock bounds are predicates over the arrival scan, never the - // ordering key: see `messages_by_arrival_in_range`. - if let Some(since_ms) = since_ms { - query = query.filter(dsl::timestamp_ms.ge(since_ms)); - } - if let Some(until_ms) = until_ms { - query = query.filter(dsl::timestamp_ms.lt(until_ms)); - } - query.order(dsl::rowid.desc()).limit(limit) -} - -impl ChatStore { - /// Chat list in a sensible default order (pinned first, then latest - /// activity). Purely a default: every ordering input (`pinned_at`, - /// `last_message_at`, `archived`, ...) is on [`ChatEntry`], so a frontend - /// with different needs re-sorts freely. - /// - /// Equivalent to [`chats_page`](Self::chats_page) with no cursor. - pub async fn chats(&self, include_archived: bool, limit: i64) -> Result> { - self.chats_page(include_archived, None, limit).await - } - - /// One page of the chat list. Pass the cursor of the last chat you already - /// have to get the page after it. - /// - /// The list is two ordered runs concatenated — pinned chats by pin time, - /// then everything else by activity — because SQLite cannot serve the - /// combined `(pinned_at IS NULL, pinned_at DESC, last_message_ts DESC)` - /// sort from any column index, and paying a full scan plus a temp B-tree - /// per call is what this shape avoids. Each run is a plain ordered range - /// scan that stops at `limit`. - pub async fn chats_page( - &self, - include_archived: bool, - after: Option, - limit: i64, - ) -> Result> { - use schema::chats::dsl; - // A negative LIMIT means "unbounded" to SQLite; never let that happen. - let limit = limit.max(0); - let device_id = self.device_id(); - let rows: Vec = self - .db() - .read(move |conn| { - // A cursor in the activity run has already passed every pinned - // chat, so that run is skipped entirely rather than re-read. - let resume_pinned = match &after { - Some(cursor) => cursor.pinned_at_ms, - None => None, - }; - let start_in_activity_run = - matches!(&after, Some(cursor) if cursor.pinned_at_ms.is_none()); - - let mut rows: Vec = Vec::new(); - if !start_in_activity_run { - let mut query = dsl::chats - .filter(dsl::device_id.eq(device_id)) - .filter(dsl::pinned_at.is_not_null()) - .into_boxed(); - if !include_archived { - query = query.filter(dsl::archived.eq(false)); - } - if let (Some(pinned_at), Some(cursor)) = (resume_pinned, &after) { - query = query.filter( - dsl::pinned_at - .lt(pinned_at) - .or(dsl::pinned_at.eq(pinned_at).and( - dsl::last_message_ts.lt(cursor.last_message_ts).or( - dsl::last_message_ts - .eq(cursor.last_message_ts) - .and(dsl::jid.lt(cursor.jid.clone())), - ), - )), - ); - } - // Activity still decides between equally-pinned chats — - // history-sync pin times are second-precision and collide, - // and the old combined sort ranked them this way too. - rows = query - .order(( - dsl::pinned_at.desc(), - dsl::last_message_ts.desc(), - dsl::jid.desc(), - )) - .limit(limit) - .load(conn) - .map_err(db_err)?; - } - - let remaining = limit - rows.len() as i64; - if remaining > 0 { - let mut query = dsl::chats - .filter(dsl::device_id.eq(device_id)) - .filter(dsl::pinned_at.is_null()) - .into_boxed(); - if !include_archived { - query = query.filter(dsl::archived.eq(false)); - } - if start_in_activity_run && let Some(cursor) = &after { - query = query.filter( - dsl::last_message_ts.lt(cursor.last_message_ts).or( - dsl::last_message_ts - .eq(cursor.last_message_ts) - .and(dsl::jid.lt(cursor.jid.clone())), - ), - ); - } - let tail: Vec = query - .order((dsl::last_message_ts.desc(), dsl::jid.desc())) - .limit(remaining) - .load(conn) - .map_err(db_err)?; - rows.extend(tail); - } - Ok(rows) - }) - .await?; - Ok(rows.into_iter().map(Into::into).collect()) - } - - /// One chat by key, or `None` if the store has never seen it. - /// - /// A 1:1 chat may be addressed by either of the peer's identities (phone - /// number or LID); both resolve to the row the thread is actually stored - /// under. This is the point lookup the primary key always supported — - /// mapping an addressed JID back to a store key, or folding one chat's - /// unread count, does not need the whole list. - /// - /// Returns one stored row, never a synthesized merge of two. While a - /// PN/LID pair is still split, sticky metadata (pin, mute, archive, name) - /// can sit on the side this does not return, exactly as it can in - /// [`chats`](Self::chats), which lists such a pair as two entries. Unioning - /// the two is [`merge_chat_metadata`]'s job and it happens on - /// reconciliation; doing it again here would put write-path precedence - /// rules in a query and make this disagree with the list. - /// - /// [`merge_chat_metadata`]: ChatStore::reconcile_chat - pub async fn chat(&self, jid: &Jid) -> Result> { - use schema::chats::dsl; - let device_id = self.device_id(); - let jid = jid.to_string(); - let row: Option = self - .db() - .read(move |conn| { - let keys = - crate::lid::chat_key_candidates(conn, device_id, &jid).map_err(db_err)?; - dsl::chats - .filter(dsl::device_id.eq(device_id).and(dsl::jid.eq_any(keys))) - // A split pair (rows under both identities, not yet merged) - // would match twice; the active thread is the one with - // activity on it. Same tiebreak as the list, so the two - // surfaces cannot disagree about which row is the thread - // when both sides carry the same activity time (common - // right after a reconcile, and whenever both are 0). - .order((dsl::last_message_ts.desc(), dsl::jid.desc())) - .first(conn) - .optional() - .map_err(db_err) - }) - .await?; - Ok(row.map(Into::into)) - } - - /// One page of a chat's messages, newest first. Pass the cursor of the - /// oldest message you already have to get the page before it. - /// - /// A 1:1 chat may be addressed by either of the peer's identities (phone - /// number or LID); the query resolves the alias, so both find the thread. - pub async fn messages( - &self, - chat: &Jid, - before: Option, - limit: i64, - ) -> Result> { - use schema::messages::dsl; - let limit = limit.max(0); - let device_id = self.device_id(); - let chat = chat.to_string(); - let rows: Vec = self - .db() - .read(move |conn| { - let keys = - crate::lid::chat_key_candidates(conn, device_id, &chat).map_err(db_err)?; - let mut query = dsl::messages - .filter(dsl::device_id.eq(device_id).and(dsl::chat_jid.eq_any(keys))) - .into_boxed(); - if let Some(cursor) = &before { - // Mirrors the sort exactly; anything looser skips or - // repeats rows at a page boundary inside a same-second run. - query = query.filter( - dsl::timestamp_ms - .lt(cursor.timestamp_ms) - .or(dsl::timestamp_ms - .eq(cursor.timestamp_ms) - .and(dsl::rowid.lt(cursor.seq))), - ); - } - query - .order((dsl::timestamp_ms.desc(), dsl::rowid.desc())) - .limit(limit) - .load(conn) - .map_err(db_err) - }) - .await?; - Ok(rows.into_iter().map(Into::into).collect()) - } - - /// One page of the whole session's messages, every chat interleaved, newest - /// arrival first. `after` is the cursor of the last row of the page you - /// have; it yields the page after that one, which is the next batch of - /// *older* arrivals. - /// - /// This is the read a reconciliation consumer wants — "everything that - /// landed since I last looked, across all chats" — which the chat list plus - /// [`messages`](Self::messages) can only answer by paging every thread. - /// Each pass re-enters at the head and walks down until it recognizes what - /// it already has; the cursor pages *within* a pass and is not carried - /// across passes: - /// - /// ```ignore - /// let mut after = None; - /// loop { - /// let page = store.messages_by_arrival(after, 100).await?; - /// let Some(oldest) = page.last() else { break }; - /// after = Some(oldest.into()); - /// // Stop on content, never on a remembered `seq` — see below. - /// if page.iter().all(|m| already_stored(&m.chat_jid, &m.id)) { break } - /// // ... take the ones that are new ... - /// } - /// ``` - /// - /// Two ways to get this wrong, both silent: - /// - /// Passing a remembered cursor as `after` does the opposite of what it - /// reads like — it asks for rows *older* than that point, so the consumer - /// walks back into its own history and never sees a new message. - /// - /// Stopping at a remembered `seq` skips messages. `seq` is the implicit - /// rowid, which SQLite assigns as `max(rowid) + 1`: deleting the newest - /// message hands its number to the next arrival, clearing a chat entirely - /// restarts at 1, and a `VACUUM` renumbers independently of all that. Each - /// of those puts a genuinely new message at or below a remembered value, - /// where a watermark comparison reads it as already seen. Deleting and - /// clearing are ordinary app-state events this store applies, so it is - /// routine rather than a corner case. Compare content across passes — - /// `(chat_jid, id)` is the stable identity. - /// - /// Equivalent to [`messages_by_arrival_in_range`](Self::messages_by_arrival_in_range) - /// with no bounds. - pub async fn messages_by_arrival( - &self, - after: Option, - limit: i64, - ) -> Result> { - self.messages_by_arrival_in_range(after, None, None, limit) - .await - } - - /// The arrival feed restricted to a half-open wall-clock window, - /// `since <= timestamp < until`. Either end may be `None` for unbounded. - /// Sub-millisecond bounds are honored exactly; stored timestamps are whole - /// milliseconds, so each end resolves to the first one at or after it. - /// - /// The window is a filter over the scan, not a seek: cost tracks the rows - /// walked, not the rows returned, so a narrow window over an old part of a - /// large store reads everything newer than it before yielding anything. - /// Narrowing that would take a `(device_id, timestamp_ms)` index, which - /// costs every message write; the feed itself does not need one. - /// - /// # Ordering - /// - /// Arrival, not timestamp — [`StoredMessage::seq`] descending. History-sync - /// backfill inserts old conversations at new `seq`, so a poller keyed on - /// `timestamp` would skip those rows forever while an arrival-keyed one - /// sees them on its next pull. Paging runs newest-first because that is the - /// direction a volatile cursor survives: every pass re-enters at the head, - /// so nothing depends on a `seq` still meaning what it did last time. - /// - /// # Arrival, not change - /// - /// A tombstone or an undecryptable placeholder is a row like any other and - /// appears here. A *mutation* of a row does not: an edit, a revoke, a star - /// or a status change rewrites the row in place, and `seq` is assigned by - /// the INSERT and survives every UPDATE, so a message the consumer has - /// already walked past never resurfaces at the head no matter what happens - /// to it afterwards. A consumer that has to track those subscribes to - /// [`StoreChange::Messages`](crate::types::StoreChange::Messages) via - /// [`ChatStore::subscribe`] and re-reads the chat it names; this feed - /// answers "what has arrived", not "what has changed". - /// - /// # Cost - /// - /// A reverse walk of the `messages` B-tree, which is why the session-wide - /// read needs no index of its own. The session's `device_id` rides along as - /// a predicate, so a database file holding several devices walks past its - /// siblings' rows to fill a page. - pub async fn messages_by_arrival_in_range( - &self, - after: Option, - since: Option>, - until: Option>, - limit: i64, - ) -> Result> { - // A negative LIMIT means "unbounded" to SQLite; never let that happen. - let limit = limit.max(0); - let device_id = self.device_id(); - let since_ms = since.map(ceil_to_ms); - let until_ms = until.map(ceil_to_ms); - let rows: Vec = self - .db() - .read(move |conn| { - arrival_page_query(device_id, after, since_ms, until_ms, limit) - .load(conn) - .map_err(db_err) - }) - .await?; - Ok(rows.into_iter().map(Into::into).collect()) - } - - pub async fn message(&self, chat: &Jid, msg_id: &str) -> Result> { - use schema::messages::dsl; - let device_id = self.device_id(); - let chat = chat.to_string(); - let msg_id = msg_id.to_owned(); - let row: Option = self - .db() - .read(move |conn| { - let keys = - crate::lid::chat_key_candidates(conn, device_id, &chat).map_err(db_err)?; - dsl::messages - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq_any(keys)) - .and(dsl::msg_id.eq(&msg_id)), - ) - .first(conn) - .optional() - .map_err(db_err) - }) - .await?; - Ok(row.map(Into::into)) - } - - pub async fn reactions(&self, chat: &Jid, msg_id: &str) -> Result> { - use schema::reactions::dsl; - let device_id = self.device_id(); - let chat = chat.to_string(); - let msg_id = msg_id.to_owned(); - let rows: Vec<(String, String, i64)> = self - .db() - .read(move |conn| { - let keys = - crate::lid::chat_key_candidates(conn, device_id, &chat).map_err(db_err)?; - dsl::reactions - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq_any(keys)) - .and(dsl::msg_id.eq(&msg_id)) - .and(dsl::emoji.ne("")), - ) - .select((dsl::sender_jid, dsl::emoji, dsl::ts_ms)) - .order(dsl::ts_ms.asc()) - .load(conn) - .map_err(db_err) - }) - .await?; - Ok(rows - .into_iter() - .map(|(sender, emoji, ts)| ReactionEntry { - sender_jid: parse_jid(&sender), - emoji, - timestamp: ms_to_utc(ts).unwrap_or_default(), - }) - .collect()) - } - - /// Per-user receipts of one message (group "delivered to"/"read by"). - pub async fn receipts(&self, chat: &Jid, msg_id: &str) -> Result> { - use schema::message_receipts::dsl; - let device_id = self.device_id(); - let chat = chat.to_string(); - let msg_id = msg_id.to_owned(); - let rows: Vec<(String, i32, i64)> = self - .db() - .read(move |conn| { - let keys = - crate::lid::chat_key_candidates(conn, device_id, &chat).map_err(db_err)?; - dsl::message_receipts - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq_any(keys)) - .and(dsl::msg_id.eq(&msg_id)), - ) - .select((dsl::user_jid, dsl::receipt_type, dsl::ts_ms)) - .order(dsl::ts_ms.asc()) - .load(conn) - .map_err(db_err) - }) - .await?; - Ok(rows - .into_iter() - .map(|(user, status, ts)| ReceiptEntry { - user_jid: parse_jid(&user), - status: MessageStatus::from_raw(status), - timestamp: ms_to_utc(ts).unwrap_or_default(), - }) - .collect()) - } - - pub async fn contact(&self, jid: &Jid) -> Result> { - use schema::contacts::dsl; - let device_id = self.device_id(); - // Bare key, matching how the writers file contacts: a caller holding a - // message's `sender` has the device on it. - let jid_str = jid.to_non_ad_string(); - let row: Option = self - .db() - .read(move |conn| { - dsl::contacts - .filter(dsl::device_id.eq(device_id).and(dsl::jid.eq(&jid_str))) - .select(( - dsl::jid, - dsl::push_name, - dsl::full_name, - dsl::first_name, - dsl::business_name, - )) - .first(conn) - .optional() - .map_err(db_err) - }) - .await?; - Ok(row.map( - |(jid, push_name, full_name, first_name, business_name)| ContactEntry { - jid: parse_jid(&jid), - push_name, - full_name, - first_name, - business_name, - }, - )) - } - - /// Sum of positive unread counters (ignores "marked unread" sentinels). - pub async fn unread_total(&self) -> Result { - use schema::chats::dsl; - let device_id = self.device_id(); - let total: Option = self - .db() - .read(move |conn| { - dsl::chats - .filter(dsl::device_id.eq(device_id).and(dsl::unread_count.gt(0))) - .select(diesel::dsl::sum(dsl::unread_count)) - .first(conn) - .map_err(db_err) - }) - .await?; - Ok(total.unwrap_or(0)) - } - - /// Record where a downloaded media blob lives locally, keyed by content - /// hash so identical files are stored once. - pub async fn put_media_ref( - &self, - file_sha256: Vec, - file_path: String, - mime_type: Option, - size_bytes: Option, - ) -> Result<()> { - use schema::media_refs::dsl; - let device_id = self.device_id(); - let now_ms = wacore::time::now_utc().timestamp_millis(); - self.db() - .run(move |conn| { - diesel::insert_into(dsl::media_refs) - .values(( - dsl::device_id.eq(device_id), - dsl::file_sha256.eq(&file_sha256), - dsl::file_path.eq(&file_path), - dsl::mime_type.eq(&mime_type), - dsl::size_bytes.eq(size_bytes), - dsl::downloaded_at_ms.eq(now_ms), - )) - .on_conflict((dsl::device_id, dsl::file_sha256)) - .do_update() - .set(( - dsl::file_path.eq(&file_path), - dsl::mime_type.eq(&mime_type), - dsl::size_bytes.eq(size_bytes), - dsl::downloaded_at_ms.eq(now_ms), - )) - .execute(conn) - .map(|_| ()) - .map_err(db_err) - }) - .await?; - Ok(()) - } - - pub async fn media_ref(&self, file_sha256: &[u8]) -> Result> { - use schema::media_refs::dsl; - let device_id = self.device_id(); - let sha = file_sha256.to_vec(); - let row: Option = self - .db() - .read(move |conn| { - dsl::media_refs - .filter(dsl::device_id.eq(device_id).and(dsl::file_sha256.eq(&sha))) - .select(( - dsl::file_sha256, - dsl::file_path, - dsl::mime_type, - dsl::size_bytes, - dsl::downloaded_at_ms, - )) - .first(conn) - .optional() - .map_err(db_err) - }) - .await?; - Ok(row.map( - |(file_sha256, file_path, mime_type, size_bytes, downloaded_at_ms)| MediaRef { - file_sha256, - file_path, - mime_type, - size_bytes, - downloaded_at: ms_to_utc(downloaded_at_ms).unwrap_or_default(), - }, - )) - } -} - -#[cfg(test)] -mod tests { - use super::{ArrivalCursor, arrival_page_query}; - use diesel::prelude::*; - use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; - - const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); - - #[derive(diesel::QueryableByName)] - struct PlanRow { - #[diesel(sql_type = diesel::sql_types::Text)] - detail: String, - } - - /// `EXPLAIN QUERY PLAN` for the query as diesel actually renders it. Binds - /// stay unbound: the planner does not need their values, and asking it - /// about hand-written SQL would pin a string this crate never runs. - fn plan(sql: &str) -> String { - let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite"); - conn.run_pending_migrations(MIGRATIONS).expect("migrate"); - let rows: Vec = diesel::sql_query(format!("EXPLAIN QUERY PLAN {sql}")) - .load(&mut conn) - .expect("explain"); - rows.into_iter() - .map(|row| row.detail) - .collect::>() - .join("\n") - } - - fn rendered_sql(after: Option, since_ms: Option) -> String { - let query = arrival_page_query(1, after, since_ms, None, 50); - let debug = diesel::debug_query::(&query).to_string(); - // `debug_query` appends the bind list after the statement. - match debug.split_once(" -- binds") { - Some((sql, _)) => sql.to_string(), - None => debug, - } - } - - /// The whole point of ordering the feed by arrival: SQLite answers it by - /// walking the `messages` B-tree backwards — a plain reverse `SCAN`, or a - /// `SEARCH ... USING INTEGER PRIMARY KEY` that seeks to the cursor first — - /// so a page costs no index and no sort. - /// - /// Left to itself the planner does the opposite: `idx_messages_by_id` leads - /// with `device_id`, so it enters there and pays a temp B-tree to recover - /// rowid order, turning every page into a full sort of the device's - /// messages. That is what the `+device_id` in the query prevents, and this - /// is the test that notices if it stops working. - #[test] - fn arrival_page_reads_the_table_in_arrival_order_without_sorting() { - for (label, sql) in [ - ("first page", rendered_sql(None, None)), - ( - "resumed page", - rendered_sql(Some(ArrivalCursor { seq: 4_096 }), None), - ), - ("windowed page", rendered_sql(None, Some(1_700_000_000_000))), - ] { - let plan = plan(&sql); - assert!( - // Any `INDEX`, not just `USING INDEX`: SQLite also spells the - // regressed plans `USING COVERING INDEX` and `USING AUTOMATIC - // COVERING INDEX`, and the plans this test wants name neither - // (`INTEGER PRIMARY KEY` is the table). - plan.contains("messages") && !plan.contains("INDEX"), - "{label}: expected the table itself, got:\n{plan}" - ); - assert!( - !plan.contains("TEMP B-TREE"), - "{label}: ordering must stream from the table, got:\n{plan}" - ); - } - } -} diff --git a/storages/chat-store/src/schema.rs b/storages/chat-store/src/schema.rs deleted file mode 100644 index 6425ac2de..000000000 --- a/storages/chat-store/src/schema.rs +++ /dev/null @@ -1,92 +0,0 @@ -diesel::table! { - chats (device_id, jid) { - device_id -> Integer, - jid -> Text, - name -> Nullable, - last_message_ts -> BigInt, - last_message_preview -> Nullable, - last_message_kind -> Nullable, - unread_count -> Integer, - pinned_at -> Nullable, - muted_until -> Nullable, - archived -> Bool, - ephemeral_expiration -> Nullable, - read_boundary_ms -> BigInt, - read_boundary_ids -> Nullable, - } -} - -diesel::table! { - messages (device_id, chat_jid, msg_id) { - device_id -> Integer, - chat_jid -> Text, - msg_id -> Text, - sender_jid -> Text, - from_me -> Bool, - timestamp_ms -> BigInt, - kind -> Text, - text_content -> Nullable, - proto -> Nullable, - status -> Integer, - starred -> Bool, - edited_at_ms -> Nullable, - revoked -> Bool, - // SQLite's implicit arrival counter, declared so the reader can sort - // and page on it. Never written: it is assigned by the INSERT and - // survives every UPDATE the writer does, which is exactly the - // "order the socket delivered this row" the message sort needs. - rowid -> BigInt, - } -} - -diesel::table! { - reactions (device_id, chat_jid, msg_id, sender_jid) { - device_id -> Integer, - chat_jid -> Text, - msg_id -> Text, - sender_jid -> Text, - emoji -> Text, - ts_ms -> BigInt, - } -} - -diesel::table! { - contacts (device_id, jid) { - device_id -> Integer, - jid -> Text, - push_name -> Nullable, - full_name -> Nullable, - first_name -> Nullable, - business_name -> Nullable, - } -} - -diesel::table! { - message_receipts (device_id, chat_jid, msg_id, user_jid, receipt_type) { - device_id -> Integer, - chat_jid -> Text, - msg_id -> Text, - user_jid -> Text, - receipt_type -> Integer, - ts_ms -> BigInt, - } -} - -diesel::table! { - media_refs (device_id, file_sha256) { - device_id -> Integer, - file_sha256 -> Binary, - file_path -> Text, - mime_type -> Nullable, - size_bytes -> Nullable, - downloaded_at_ms -> BigInt, - } -} - -diesel::allow_tables_to_appear_in_same_query!( - chats, - messages, - reactions, - contacts, - message_receipts -); diff --git a/storages/chat-store/src/store.rs b/storages/chat-store/src/store.rs deleted file mode 100644 index e7f1f43be..000000000 --- a/storages/chat-store/src/store.rs +++ /dev/null @@ -1,3062 +0,0 @@ -//! The store itself: a write-behind materializer over the client's event -//! stream plus the public write API. All writes funnel through one writer task -//! (one transaction per drained batch), so event order is preserved and fan-in -//! bursts don't pay per-event commit costs. - -use std::borrow::Cow; -use std::collections::BTreeSet; -use std::str::FromStr; -use std::sync::Arc; - -use chrono::{DateTime, Utc}; -use diesel::prelude::*; -use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; -use log::warn; -use tokio::sync::{broadcast, mpsc, oneshot}; -use wacore::store::error::StoreError; -use wacore::types::events::{Event, EventHandler, EventInterest, EventKind, InboundMessage}; -use wacore::types::presence::ReceiptType; -use wacore_binary::{Jid, JidExt as _}; -use waproto::whatsapp as wa; -use whatsapp_rust_sqlite_storage::{SharedSqlite, SqliteStore}; - -use crate::error::{ChatStoreError, Result, db_err}; -use crate::materialize::{ - KIND_UNDECRYPTABLE, MessageOp, classify, extract_text, message_kind, unavailable_kind, -}; -use crate::schema; -use crate::types::StoreChange; - -const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); - -/// Max events applied per transaction. Bounds transaction size during -/// offline-drain bursts; the writer loops immediately for the remainder. -const BATCH_MAX: usize = 128; - -/// Capacity of the invalidation broadcast. Lagging receivers see -/// `RecvError::Lagged` and should re-query everything they display. -const CHANGE_CHANNEL_CAPACITY: usize = 256; - -/// Manually-marked-unread sentinel for `chats.unread_count` (WA Web convention). -const UNREAD_MARKER: i32 = -1; - -pub(crate) enum WriterMsg { - Event(Arc), - Outgoing { - chat: Jid, - msg_id: String, - proto: Vec, - kind: &'static str, - text: Option, - timestamp_ms: i64, - }, - Edit { - chat: Jid, - target_id: String, - proto: Vec, - kind: &'static str, - text: Option, - timestamp_ms: i64, - }, - Revoke { - chat: Jid, - target_id: String, - timestamp_ms: i64, - }, - Reaction { - chat: Jid, - target_id: String, - target_from_me: bool, - target_participant: Option, - emoji: String, - timestamp_ms: i64, - }, - Reconcile(Jid), - // String, not StoreError: one batch outcome fans out to many waiters and - // StoreError is not Clone. - Flush(oneshot::Sender>), -} - -/// SQLite-backed chat/message/contact history, materialized from the client's -/// event stream into the same database file as the device store. -/// -/// Wire-up: -/// ```ignore -/// let chat_store = ChatStore::new(&sqlite_store).await?; -/// let _chat_subscription = client.subscribe_handler(chat_store.handler()); -/// let mut changes = chat_store.subscribe(); -/// ``` -pub struct ChatStore { - db: SharedSqlite, - device_id: i32, - tx: mpsc::UnboundedSender, - changes: broadcast::Sender, - skip_hook_committed: Arc, -} - -struct ChatStoreHandler { - tx: mpsc::UnboundedSender, - skip_hook_committed: Arc, -} - -impl EventHandler for ChatStoreHandler { - fn handle_event(&self, event: Arc) { - // `hook_committed` says a durability hook committed the batch — NOT - // that it committed it *here*. A hook that persists somewhere else - // entirely is just as common, and for that host this store is the only - // materializer; skipping would silently lose acknowledged messages. - // Only the host knows which it runs, so the skip is opt-in and this - // load is the answer it gave (see `skip_hook_committed_batches`). - if self - .skip_hook_committed - .load(std::sync::atomic::Ordering::Relaxed) - && event - .as_messages() - .is_some_and(|batch| batch.hook_committed) - { - return; - } - // Writer gone (store dropped): nothing to record into, drop silently. - let _ = self.tx.send(WriterMsg::Event(event)); - } - - fn interest(&self) -> EventInterest { - EventInterest::of(&[ - EventKind::Messages, - EventKind::Receipt, - EventKind::ServerAck, - EventKind::UndecryptableMessage, - EventKind::HistorySync, - EventKind::ContactUpdate, - EventKind::PinUpdate, - EventKind::MuteUpdate, - EventKind::ArchiveUpdate, - EventKind::StarUpdate, - EventKind::MarkChatAsReadUpdate, - EventKind::DeleteChatUpdate, - EventKind::ClearChatUpdate, - EventKind::DeleteMessageForMeUpdate, - ]) - } -} - -impl ChatStore { - /// Open (running migrations if needed) on the same database file as - /// `store`, bound to its device id, and start the writer task. - pub async fn new(store: &SqliteStore) -> Result> { - let db = store.shared(); - let device_id = store.device_id(); - - db.run(|conn| { - conn.run_pending_migrations(MIGRATIONS) - .map(|_| ()) - .map_err(StoreError::Migration)?; - #[cfg(feature = "search")] - crate::fts::ensure_fts(conn).map_err(db_err)?; - Ok(()) - }) - .await?; - - let (tx, rx) = mpsc::unbounded_channel(); - let (changes, _) = broadcast::channel(CHANGE_CHANNEL_CAPACITY); - - let this = Arc::new(Self { - db: db.clone(), - device_id, - tx, - changes: changes.clone(), - skip_hook_committed: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tokio::spawn(writer_loop(db, device_id, rx, changes)); - Ok(this) - } - - /// Declare that this client's inbound durability hook already materializes - /// into THIS store, so batches it committed can be skipped here. - /// - /// Off by default, and deliberately not inferred: a batch's - /// `hook_committed` marker says a hook committed it, not that the hook - /// wrote it *here*. A host whose hook persists elsewhere — its own - /// database, a queue, an audit log — still needs this store to materialize - /// every batch, and skipping on the marker alone would silently drop - /// acknowledged messages out of its history, previews and subscriptions. - /// Only the host knows which arrangement it runs. - /// - /// Turn it on when the hook feeds this store and you would otherwise pay - /// for every message twice: the inbound path overwrites, so the second - /// pass is a full UPDATE of the proto blob plus an FTS delete+insert plus - /// another chat bump, and it doubles the `StoreChange` fan-out, so every - /// subscriber re-queries every surface twice per message. - /// - /// Takes effect on the next event; handlers already handed out observe it. - pub fn skip_hook_committed_batches(&self, skip: bool) { - self.skip_hook_committed - .store(skip, std::sync::atomic::Ordering::Relaxed); - } - - /// Event handler to register on the client. The store keeps working if the - /// handler outlives it (events are then dropped), and vice versa. - pub fn handler(&self) -> Arc { - Arc::new(ChatStoreHandler { - tx: self.tx.clone(), - skip_hook_committed: Arc::clone(&self.skip_hook_committed), - }) - } - - /// Subscribe to invalidation signals. Emitted once per committed write - /// batch, deduplicated. On `Lagged`, re-query all visible state. - pub fn subscribe(&self) -> broadcast::Receiver { - self.changes.subscribe() - } - - /// Record a message this client just sent. Goes through the writer queue so - /// it cannot race the server ack / receipts that follow it in event order. - /// Status starts at [`MessageStatus::Pending`](crate::types::MessageStatus::Pending) - /// and is lifted by acks/receipts. `timestamp` is the optimistic display - /// time; a positive message ack replaces it with the server's `t` when - /// available and refreshes the conversation order. - /// - /// `chat` may be either of a 1:1 peer's identities (phone number or LID): - /// the row is stored on the peer's one thread regardless — an existing - /// thread keeps its key, a brand-new chat with a known LID mapping is - /// keyed by the LID (WA Web behavior) — and every query resolves the - /// alias, so reads by either identity keep working. - pub fn record_outgoing( - &self, - chat: &Jid, - msg_id: impl Into, - message: &wa::Message, - timestamp: DateTime, - ) -> Result<()> { - let base = wacore::proto_helpers::MessageExt::get_base_message(message); - self.tx - .send(WriterMsg::Outgoing { - chat: chat.clone(), - msg_id: msg_id.into(), - proto: waproto::codec::message_to_vec(message), - kind: message_kind(base), - text: extract_text(base), - timestamp_ms: timestamp.timestamp_millis(), - }) - .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) - } - - /// Record an edit this client just sent for one of its own messages. - /// - /// This is the local counterpart of an inbound `MESSAGE_EDIT`: it updates - /// the existing row in place (or creates the same out-of-order placeholder - /// as the event path), preserving the edit's timestamp ordering and - /// tombstone rules. Goes through the writer queue; use - /// [`flush`](Self::flush) to await completion. - pub fn record_edit( - &self, - chat: &Jid, - target_id: &str, - new_content: &wa::Message, - timestamp: DateTime, - ) -> Result<()> { - let base = wacore::proto_helpers::MessageExt::get_base_message(new_content); - self.tx - .send(WriterMsg::Edit { - chat: chat.clone(), - target_id: target_id.to_owned(), - proto: waproto::codec::message_to_vec(new_content), - kind: message_kind(base), - text: extract_text(base), - timestamp_ms: timestamp.timestamp_millis(), - }) - .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) - } - - /// Record a sender revoke this client just sent for one of its own - /// messages. - /// - /// The target becomes a tombstone and cannot be resurrected by a delayed - /// content delivery or edit. Goes through the writer queue; use - /// [`flush`](Self::flush) to await completion. - pub fn record_revoke( - &self, - chat: &Jid, - target_id: &str, - timestamp: DateTime, - ) -> Result<()> { - self.tx - .send(WriterMsg::Revoke { - chat: chat.clone(), - target_id: target_id.to_owned(), - timestamp_ms: timestamp.timestamp_millis(), - }) - .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) - } - - /// Record a reaction this client just sent. An empty `emoji` removes this - /// client's existing reaction, matching the inbound event semantics. - /// - /// `target` is the same message key passed to `Client::send_reaction` and - /// must contain an id. If no stored message matches its authorship, the - /// queued reaction is a no-op. Goes through the writer queue; use - /// [`flush`](Self::flush) to await completion. - pub fn record_reaction( - &self, - chat: &Jid, - target: &wa::MessageKey, - emoji: &str, - timestamp: DateTime, - ) -> Result<()> { - let target_id = target.id.clone().ok_or_else(|| { - ChatStoreError::Store(StoreError::Validation( - "reaction target key missing id".into(), - )) - })?; - self.tx - .send(WriterMsg::Reaction { - chat: chat.clone(), - target_id, - target_from_me: target.from_me.unwrap_or(false), - target_participant: target.participant.clone(), - emoji: emoji.to_owned(), - timestamp_ms: timestamp.timestamp_millis(), - }) - .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) - } - - /// Reconcile a 1:1 peer's PN- and LID-keyed rows into a single thread. - /// - /// Receipts dropped under the wrong identity (before this crate resolved - /// PN/LID aliases) left some stores with a split pair: a populated chat - /// under the phone-number key plus a stray `@lid` twin. Live traffic for - /// the peer now heals such a pair on its own; this makes the repair - /// on-demand for embedders that want it eagerly. Idempotent — a peer with - /// one thread (or no LID mapping yet) is a no-op. Goes through the writer - /// queue; use [`flush`](Self::flush) to await completion. - pub fn reconcile_chat(&self, chat: &Jid) -> Result<()> { - self.tx - .send(WriterMsg::Reconcile(chat.clone())) - .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) - } - - /// Wait until every write enqueued before this call is committed. Errors - /// with [`ChatStoreError::WriteBatchFailed`] when any batch since the - /// previous flush answer rolled back. The contract is TEMPORAL, not - /// per-caller: writes enqueued by anyone before this call share its fate, - /// so a failure that dropped someone else's earlier writes still reports - /// here (conservative: a false failure is possible, a false success is - /// not). - pub async fn flush(&self) -> Result<()> { - let (tx, rx) = oneshot::channel(); - self.tx - .send(WriterMsg::Flush(tx)) - .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into())))?; - rx.await - .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into())))? - .map_err(ChatStoreError::WriteBatchFailed) - } - - pub fn device_id(&self) -> i32 { - self.device_id - } - - pub(crate) fn db(&self) -> &SharedSqlite { - &self.db - } -} - -/// A sync action's message range. The wire boundary is unix SECONDS while -/// rows store milliseconds, so the boundary covers its WHOLE second; when the -/// action lists explicit boundary messages (WA Web fills `messages` exactly to -/// disambiguate same-second siblings), only the listed ids inside the boundary -/// second count as covered. -struct RangeBound { - /// First ms of the boundary second. - second_start_ms: i64, - /// Last ms of the boundary second. - second_end_ms: i64, - /// Ids the action explicitly covers at the boundary; `None` = the whole - /// boundary second is covered (sender did not enumerate). - keys: Option>, -} - -fn range_bound( - range: &buffa::MessageField, -) -> Option { - let range = range.as_option()?; - let ts_secs = range.last_message_timestamp.filter(|&ts| ts > 0)?; - let second_start_ms = ts_secs.saturating_mul(1000); - let keys: Vec = range - .messages - .iter() - .filter_map(|m| m.key.as_option().and_then(|k| k.id.clone())) - .collect(); - Some(RangeBound { - second_start_ms, - second_end_ms: second_start_ms.saturating_add(999), - keys: (!keys.is_empty()).then_some(keys), - }) -} - -/// Extra read-boundary ids kept per chat; overflow drops the oldest entries. -const READ_EXTRA_IDS_CAP: usize = 256; - -/// The chat's materialized self-read state: everything at or below the -/// watermark is read, plus the explicitly-named ids — boundary-instant/keyed -/// coverage that a scalar watermark cannot express (both directions of the -/// same-second ambiguity are lossy without them). -struct ReadState { - watermark_ms: i64, - extra_ids: Vec, -} - -impl ReadState { - fn covers(&self, ts_ms: i64, msg_id: &str) -> bool { - ts_ms <= self.watermark_ms || self.extra_ids.iter().any(|id| id == msg_id) - } -} - -fn read_state(conn: &mut SqliteConnection, device_id: i32, chat: &str) -> QueryResult { - let row: Option<(i64, Option)> = chat_row(device_id, chat) - .select(( - schema::chats::read_boundary_ms, - schema::chats::read_boundary_ids, - )) - .first(conn) - .optional()?; - let (watermark_ms, ids_json) = row.unwrap_or((0, None)); - let extra_ids = ids_json - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_default(); - Ok(ReadState { - watermark_ms, - extra_ids, - }) -} - -/// Fold a read event (watermark + explicitly covered ids) into the chat's -/// monotonic read state. Ids already implied by the watermark are pruned. -/// Returns the post-advance state, or `None` when the event brought nothing -/// new (a stale replay, which must not touch the unread badge). -fn advance_read_state( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - watermark_ms: i64, - covered_ids: &[String], -) -> QueryResult> { - use schema::messages::dsl; - let mut state = read_state(conn, device_id, chat)?; - let before = (state.watermark_ms, state.extra_ids.clone()); - if watermark_ms > state.watermark_ms { - state.watermark_ms = watermark_ms; - } - for id in covered_ids { - if !state.extra_ids.iter().any(|existing| existing == id) { - state.extra_ids.push(id.clone()); - } - } - if !state.extra_ids.is_empty() { - let implied: Vec = dsl::messages - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq(chat)) - .and(dsl::msg_id.eq_any(&state.extra_ids)) - .and(dsl::timestamp_ms.le(state.watermark_ms)), - ) - .select(dsl::msg_id) - .load(conn)?; - if !implied.is_empty() { - state.extra_ids.retain(|id| !implied.contains(id)); - } - } - if state.extra_ids.len() > READ_EXTRA_IDS_CAP { - let overflow = state.extra_ids.len() - READ_EXTRA_IDS_CAP; - state.extra_ids.drain(..overflow); - } - if (state.watermark_ms, &state.extra_ids) == (before.0, &before.1) { - return Ok(None); - } - let ids_json = (!state.extra_ids.is_empty()) - .then(|| serde_json::to_string(&state.extra_ids).ok()) - .flatten(); - diesel::update(chat_row(device_id, chat)) - .set(( - schema::chats::read_boundary_ms.eq(state.watermark_ms), - schema::chats::read_boundary_ids.eq(ids_json), - )) - .execute(conn)?; - Ok(Some(state)) -} - -/// Incoming rows not covered by the read state. -fn count_unread( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - state: &ReadState, -) -> QueryResult { - use schema::messages::dsl; - let mut query = dsl::messages - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq(chat)) - .and(dsl::from_me.eq(false)) - .and(dsl::timestamp_ms.gt(state.watermark_ms)), - ) - .into_boxed(); - if !state.extra_ids.is_empty() { - query = query.filter(dsl::msg_id.ne_all(&state.extra_ids)); - } - let unread: i64 = query.count().get_result(conn)?; - Ok(unread.min(i32::MAX as i64) as i32) -} - -/// Incoming rows NOT covered by `bound`: strictly newer than the boundary -/// second, plus same-second rows the action's keyed list does not name. -/// Rows the read state already covers don't count — a stale ranged action -/// replaying after a newer self-read must not resurrect their badge. -fn count_uncovered_incoming( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - bound: &RangeBound, -) -> QueryResult { - use schema::messages::dsl; - let state = read_state(conn, device_id, chat)?; - let mut base = dsl::messages - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq(chat)) - .and(dsl::from_me.eq(false)) - .and(dsl::timestamp_ms.gt(state.watermark_ms)), - ) - .into_boxed(); - if !state.extra_ids.is_empty() { - base = base.filter(dsl::msg_id.ne_all(state.extra_ids.clone())); - } - let uncovered: i64 = match &bound.keys { - None => base - .filter(dsl::timestamp_ms.gt(bound.second_end_ms)) - .count() - .get_result(conn)?, - Some(keys) => base - .filter(dsl::timestamp_ms.gt(bound.second_start_ms - 1)) - .filter( - dsl::timestamp_ms - .gt(bound.second_end_ms) - .or(dsl::msg_id.ne_all(keys.clone())), - ) - .count() - .get_result(conn)?, - }; - Ok(uncovered.min(i32::MAX as i64) as i32) -} - -/// Chats/contacts touched by a batch, accumulated for post-commit invalidation. -#[derive(Default)] -pub(crate) struct ChangeSet { - pub(crate) chats: bool, - pub(crate) contacts: bool, - pub(crate) message_chats: BTreeSet, -} - -async fn writer_loop( - db: SharedSqlite, - device_id: i32, - mut rx: mpsc::UnboundedReceiver, - changes: broadcast::Sender, -) { - // Sticky across iterations: a failed batch with no flush waiter of its - // own must still be reported to the NEXT flush (a >BATCH_MAX backlog spans - // several transactions). Consumed when delivered. - let mut pending_error: Option = None; - // Outlives every batch: the insert that answers a deferred ack is by - // definition in a later one. Shared with the blocking closure rather than - // moved into it, so a panic inside the transaction cannot carry the queue - // off with it — the acks a dying batch deferred are exactly the ones with - // no other record left. - let deferred_acks = Arc::new(std::sync::Mutex::new(DeferredAcks::default())); - while let Some(first) = rx.recv().await { - let mut batch = Vec::with_capacity(8); - let mut flushes = Vec::new(); - // A Flush is a batch BARRIER: stop draining there, so writes enqueued - // after a caller's flush() can neither commit ahead of that call's - // answer nor drag the awaited writes down with a later failure. - let mut queue_msg = |msg: WriterMsg, batch: &mut Vec| match msg { - WriterMsg::Flush(done) => { - flushes.push(done); - true - } - other => { - batch.push(other); - false - } - }; - let mut at_barrier = queue_msg(first, &mut batch); - while !at_barrier && batch.len() < BATCH_MAX { - match rx.try_recv() { - Ok(msg) => at_barrier = queue_msg(msg, &mut batch), - Err(_) => break, - } - } - - if !batch.is_empty() { - // Snapshot what a failure has to fold back onto. Deferred acks are - // rare, so the usual clone is of an empty queue. - let pre_batch = { - let mut acks = lock_deferred_acks(&deferred_acks); - acks.begin_batch(); - acks.clone() - }; - let shared = Arc::clone(&deferred_acks); - let result = db - .run(move |conn| { - let mut deferred = lock_deferred_acks(&shared); - conn.transaction(|conn| { - let mut cs = ChangeSet::default(); - for msg in &batch { - apply_writer_msg(conn, device_id, msg, &mut cs, &mut deferred)?; - } - Ok(cs) - }) - .map_err(db_err) - }) - .await; - match result { - Ok(cs) => emit_changes(&changes, cs), - // Nothing committed, by any route: the transaction rolled back, - // or the pool/task failed before or during it. The queue is - // reachable either way, so fold it back the same way — undoing - // what the batch consumed, keeping what it deferred. - Err(e) => { - let mut acks = lock_deferred_acks(&deferred_acks); - *acks = std::mem::take(&mut *acks).rolled_back(pre_batch); - warn!("chat-store: dropping write batch: {e}"); - pending_error = Some(e.to_string()); - } - } - } - if flushes.is_empty() { - continue; - } - let outcome = match pending_error.take() { - Some(e) => Err(e), - None => Ok(()), - }; - for done in flushes { - let _ = done.send(outcome.clone()); - } - } -} - -/// Take the deferred-ack queue, poisoned or not. -/// -/// Poisoning here means the writer's transaction panicked mid-batch, and the -/// contents are precisely what has to be recovered in that case — the acks it -/// had deferred have no other record. Refusing to read them would turn the -/// panic into the silent loss the queue exists to prevent. -fn lock_deferred_acks( - acks: &std::sync::Mutex, -) -> std::sync::MutexGuard<'_, DeferredAcks> { - acks.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -fn emit_changes(changes: &broadcast::Sender, cs: ChangeSet) { - if cs.chats { - let _ = changes.send(StoreChange::Chats); - } - if cs.contacts { - let _ = changes.send(StoreChange::Contacts); - } - for chat in cs.message_chats { - if let Ok(jid) = Jid::from_str(&chat) { - let _ = changes.send(StoreChange::Messages { chat: jid }); - } - } -} - -fn apply_writer_msg( - conn: &mut SqliteConnection, - device_id: i32, - msg: &WriterMsg, - cs: &mut ChangeSet, - deferred: &mut DeferredAcks, -) -> QueryResult<()> { - match msg { - WriterMsg::Event(event) => apply_event(conn, device_id, event, cs, deferred), - WriterMsg::Reconcile(chat) => { - let wire = chat.to_string(); - if let Some(alt) = crate::lid::counterpart_chat_key(conn, device_id, &wire)? { - crate::lid::merge_split_chat(conn, device_id, &wire, &alt, cs)?; - } - Ok(()) - } - WriterMsg::Outgoing { - chat, - msg_id, - proto, - kind, - text, - timestamp_ms, - } => { - let chat_str = route_writer_chat(conn, device_id, chat, cs)?; - let stored = insert_message( - conn, - device_id, - NewMessage { - chat_jid: &chat_str, - msg_id, - sender_jid: "", - from_me: true, - timestamp_ms: *timestamp_ms, - kind, - text: text.as_deref(), - proto: Some(proto), - status: wa::web_message_info::Status::PENDING as i32, - starred: false, - overwrite: true, - }, - )?; - if stored != StoredRow::Skipped { - bump_chat( - conn, - device_id, - &chat_str, - ChatBump { - msg_id, - ts_ms: *timestamp_ms, - preview: text.as_deref(), - kind: Some(kind), - unread_delta: 0, - }, - )?; - cs.chats = true; - // The row this send's ack was waiting for now exists. Applying - // it here also corrects the optimistic timestamp we just wrote - // to the server's, before anything renders the row. - if let Some(ack) = deferred.take_matching( - msg_id, - &chat_str, - wacore::time::now_utc().timestamp_millis(), - ) && let AckApplied::Deferrable(_) = apply_server_ack(conn, device_id, &ack, cs)? - { - // The row exists, so this should not happen; say so rather - // than let the ack vanish the way it used to. - warn!( - target: "ChatStore/Ack", - "Held ack for {msg_id} matched no row even after its insert" - ); - } - } - cs.message_chats.insert(chat_str); - Ok(()) - } - WriterMsg::Edit { - chat, - target_id, - proto, - kind, - text, - timestamp_ms, - } => { - let chat_str = route_writer_chat(conn, device_id, chat, cs)?; - if !local_target_collides_with_peer(conn, device_id, &chat_str, target_id)? - && apply_edit( - conn, - device_id, - &chat_str, - target_id, - "", - true, - text.as_deref(), - kind, - proto, - *timestamp_ms, - )? - { - cs.chats = true; - } - cs.message_chats.insert(chat_str); - Ok(()) - } - WriterMsg::Revoke { - chat, - target_id, - timestamp_ms, - } => { - let chat_str = route_writer_chat(conn, device_id, chat, cs)?; - if !local_target_collides_with_peer(conn, device_id, &chat_str, target_id)? - && apply_revoke( - conn, - device_id, - &chat_str, - target_id, - "", - true, - *timestamp_ms, - )? - { - cs.chats = true; - } - cs.message_chats.insert(chat_str); - Ok(()) - } - WriterMsg::Reaction { - chat, - target_id, - target_from_me, - target_participant, - emoji, - timestamp_ms, - } => { - let chat_str = route_writer_chat(conn, device_id, chat, cs)?; - if local_reaction_target_matches( - conn, - device_id, - &chat_str, - target_id, - *target_from_me, - target_participant.as_deref(), - )? { - // Own reactors are stored as the empty JID, the same sentinel - // used by history sync for key.from_me reactions. - apply_reaction( - conn, - device_id, - &chat_str, - target_id, - "", - emoji, - *timestamp_ms, - )?; - } - cs.message_chats.insert(chat_str); - Ok(()) - } - WriterMsg::Flush(_) => Ok(()), - } -} - -fn route_writer_chat( - conn: &mut SqliteConnection, - device_id: i32, - chat: &Jid, - cs: &mut ChangeSet, -) -> QueryResult { - let wire = chat.to_string(); - let routed = crate::lid::route_chat_key(conn, device_id, &wire, cs)?; - if routed != wire { - cs.message_chats.insert(wire); - } - Ok(routed) -} - -/// A local amendment may create an own-message placeholder when its target is -/// absent, but an existing peer row with the same sender-chosen id belongs to -/// a different message and must remain untouched. -fn local_target_collides_with_peer( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - target_id: &str, -) -> QueryResult { - diesel::select(diesel::dsl::exists( - message_row(device_id, chat, target_id).filter(schema::messages::from_me.eq(false)), - )) - .get_result(conn) -} - -/// Match the full target identity, not just its sender-chosen id. Device -/// suffixes and known PN/LID aliases normalize before participant comparison. -fn local_reaction_target_matches( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - target_id: &str, - target_from_me: bool, - target_participant: Option<&str>, -) -> QueryResult { - let target: Option<(bool, String)> = message_row(device_id, chat, target_id) - .select((schema::messages::from_me, schema::messages::sender_jid)) - .first(conn) - .optional()?; - let Some((stored_from_me, stored_sender)) = target else { - return Ok(false); - }; - if stored_from_me != target_from_me { - return Ok(false); - } - if target_from_me { - return Ok(true); - } - let Some(participant) = target_participant else { - let needs_participant = Jid::from_str(chat).is_ok_and(|jid| { - jid.is_group() || jid.is_status_broadcast() || jid.is_broadcast_list() - }); - return Ok(!needs_participant); - }; - let (Ok(stored), Ok(target)) = (Jid::from_str(&stored_sender), Jid::from_str(participant)) - else { - return Ok(stored_sender == participant); - }; - let stored = stored.to_non_ad_string(); - let target = target.to_non_ad_string(); - if stored == target { - return Ok(true); - } - Ok( - crate::lid::counterpart_chat_key(conn, device_id, &stored)?.as_deref() - == Some(target.as_str()), - ) -} - -fn apply_event( - conn: &mut SqliteConnection, - device_id: i32, - event: &Event, - cs: &mut ChangeSet, - deferred: &mut DeferredAcks, -) -> QueryResult<()> { - match event { - Event::Messages(batch) => { - for inbound in batch.iter() { - apply_inbound(conn, device_id, inbound, cs)?; - } - Ok(()) - } - Event::Receipt(receipt) => apply_receipt(conn, device_id, receipt, cs), - Event::ServerAck(ack) => { - if let AckApplied::Deferrable(chat) = apply_server_ack(conn, device_id, ack, cs)? { - deferred.defer(ack, chat, wacore::time::now_utc().timestamp_millis()); - } - Ok(()) - } - Event::UndecryptableMessage(undec) => { - let kind = unavailable_kind(undec.unavailable_type).unwrap_or(KIND_UNDECRYPTABLE); - let wire = undec.info.source.chat.to_string(); - let chat = crate::lid::route_chat_key(conn, device_id, &wire, cs)?; - if chat != wire { - cs.message_chats.insert(wire); - } - let sender = undec.info.source.sender.to_string(); - let inserted = insert_message( - conn, - device_id, - NewMessage { - chat_jid: &chat, - msg_id: &undec.info.id, - sender_jid: &sender, - from_me: undec.info.source.is_from_me, - timestamp_ms: undec.info.timestamp.timestamp_millis(), - kind, - text: None, - proto: None, - status: wa::web_message_info::Status::DELIVERY_ACK as i32, - starred: false, - overwrite: false, - }, - )?; - // A duplicate placeholder (or one for an id that was already - // recovered/revoked) must neither recount nor blank the preview. - if inserted == StoredRow::Inserted { - bump_chat( - conn, - device_id, - &chat, - ChatBump { - msg_id: &undec.info.id, - ts_ms: undec.info.timestamp.timestamp_millis(), - preview: None, - kind: Some(kind), - unread_delta: i32::from(!undec.info.source.is_from_me), - }, - )?; - cs.chats = true; - } - cs.message_chats.insert(chat); - Ok(()) - } - Event::HistorySync(lazy) => apply_history_sync(conn, device_id, lazy, cs), - Event::ContactUpdate(update) => { - upsert_contact_names( - conn, - device_id, - &update.jid.to_string(), - update.action.full_name.as_deref(), - update.action.first_name.as_deref(), - )?; - cs.contacts = true; - Ok(()) - } - Event::PinUpdate(update) => { - let pinned_at = update - .action - .pinned - .unwrap_or(false) - .then(|| update.timestamp.timestamp_millis()); - let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; - ensure_chat(conn, device_id, &chat)?; - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::pinned_at.eq(pinned_at)) - .execute(conn)?; - cs.chats = true; - Ok(()) - } - Event::MuteUpdate(update) => { - let muted_until = if update.action.muted.unwrap_or(false) { - // Absent or non-positive (WA Web sends -1 for indefinite, - // this crate's own mute_chat() included) = muted forever. - Some( - update - .action - .mute_end_timestamp - .filter(|&ts| ts > 0) - .unwrap_or(i64::MAX), - ) - } else { - None - }; - let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; - ensure_chat(conn, device_id, &chat)?; - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::muted_until.eq(muted_until)) - .execute(conn)?; - cs.chats = true; - Ok(()) - } - Event::ArchiveUpdate(update) => { - let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; - ensure_chat(conn, device_id, &chat)?; - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::archived.eq(update.action.archived.unwrap_or(false))) - .execute(conn)?; - cs.chats = true; - Ok(()) - } - Event::MarkChatAsReadUpdate(update) => { - let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; - ensure_chat(conn, device_id, &chat)?; - if update.action.read.unwrap_or(false) { - // A delayed replay only covers messages up to its range; - // anything we materialized past it is still unread. Reads - // fold into the monotonic read state (watermark + keyed - // boundary ids), so later stale actions/receipts can't - // resurrect the badge — and a stale replay itself changes - // nothing. - let advanced = match range_bound(&update.action.message_range) { - Some(bound) => { - // A keyed boundary second can't be expressed by the - // watermark alone: it stops short and the named ids - // ride along in the state. - let (watermark, ids): (i64, &[String]) = match &bound.keys { - Some(keys) => (bound.second_start_ms - 1, keys.as_slice()), - None => (bound.second_end_ms, &[]), - }; - advance_read_state(conn, device_id, &chat, watermark, ids)? - } - None => { - use schema::messages::dsl; - let newest: Option> = dsl::messages - .filter(dsl::device_id.eq(device_id).and(dsl::chat_jid.eq(&chat))) - .select(diesel::dsl::max(dsl::timestamp_ms)) - .first(conn) - .optional()?; - // Empty chat: the action's own timestamp is the read - // moment — the state must still advance, or a later - // stale replay resurrects a badge this read cleared. - let watermark = newest - .flatten() - .unwrap_or_else(|| update.timestamp.timestamp_millis()); - advance_read_state(conn, device_id, &chat, watermark, &[])? - } - }; - match advanced { - Some(state) => { - let unread = count_unread(conn, device_id, &chat, &state)?; - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::unread_count.eq(unread)) - .execute(conn)?; - } - // Cursor didn't move (re-reading an already-read chat), - // but a read still clears a manual-unread marker. - None => { - let state = read_state(conn, device_id, &chat)?; - let unread = count_unread(conn, device_id, &chat, &state)?; - diesel::update( - chat_row(device_id, &chat) - .filter(schema::chats::unread_count.eq(UNREAD_MARKER)), - ) - .set(schema::chats::unread_count.eq(unread)) - .execute(conn)?; - } - } - } else { - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::unread_count.eq(UNREAD_MARKER)) - .execute(conn)?; - } - cs.chats = true; - Ok(()) - } - Event::StarUpdate(update) => { - let chat = - crate::lid::route_chat_key(conn, device_id, &update.chat_jid.to_string(), cs)?; - diesel::update(message_row(device_id, &chat, &update.message_id)) - .set(schema::messages::starred.eq(update.action.starred.unwrap_or(false))) - .execute(conn)?; - cs.message_chats.insert(chat); - Ok(()) - } - Event::DeleteChatUpdate(update) => { - let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; - let bound = range_bound(&update.action.message_range); - delete_chat_rows(conn, device_id, &chat, true, bound.as_ref())?; - // A delayed delete only covers up to its range: when newer - // messages were already materialized locally, the chat survives - // with them instead of vanishing. - let survivors = remaining_messages(conn, device_id, &chat)?; - match &bound { - Some(bound) if survivors > 0 => { - recompute_chat_preview(conn, device_id, &chat)?; - let unread = count_uncovered_incoming(conn, device_id, &chat, bound)?; - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::unread_count.eq(unread)) - .execute(conn)?; - } - _ => { - diesel::delete(chat_row(device_id, &chat)).execute(conn)?; - } - } - cs.chats = true; - cs.message_chats.insert(chat); - Ok(()) - } - Event::ClearChatUpdate(update) => { - let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; - let bound = range_bound(&update.action.message_range); - delete_chat_rows( - conn, - device_id, - &chat, - update.delete_starred, - bound.as_ref(), - )?; - // Starred rows (and messages newer than the range) may survive the - // clear: the preview/kind must reflect the newest survivor, not go - // blank (or keep stale kind). - recompute_chat_preview(conn, device_id, &chat)?; - // Unread survivors past a ranged clear keep their badge; an - // unranged clear empties the chat, so zero is exact there. - let unread = match &bound { - Some(bound) => count_uncovered_incoming(conn, device_id, &chat, bound)?, - None => 0, - }; - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::unread_count.eq(unread)) - .execute(conn)?; - cs.chats = true; - cs.message_chats.insert(chat); - Ok(()) - } - Event::DeleteMessageForMeUpdate(update) => { - let chat = - crate::lid::route_chat_key(conn, device_id, &update.chat_jid.to_string(), cs)?; - // Capture the victim's read state before it goes: deleting an - // unread inbound row must also drop its badge (sentinel -1 and - // already-read rows are untouched). - let victim: Option<(bool, i64)> = message_row(device_id, &chat, &update.message_id) - .select((schema::messages::from_me, schema::messages::timestamp_ms)) - .first(conn) - .optional()?; - diesel::delete(message_row(device_id, &chat, &update.message_id)).execute(conn)?; - if let Some((false, ts_ms)) = victim - && !read_state(conn, device_id, &chat)?.covers(ts_ms, &update.message_id) - { - diesel::update( - chat_row(device_id, &chat).filter(schema::chats::unread_count.gt(0)), - ) - .set(schema::chats::unread_count.eq(schema::chats::unread_count - 1)) - .execute(conn)?; - } - diesel::delete( - schema::reactions::table.filter( - schema::reactions::device_id - .eq(device_id) - .and(schema::reactions::chat_jid.eq(&chat)) - .and(schema::reactions::msg_id.eq(&update.message_id)), - ), - ) - .execute(conn)?; - diesel::delete( - schema::message_receipts::table.filter( - schema::message_receipts::device_id - .eq(device_id) - .and(schema::message_receipts::chat_jid.eq(&chat)) - .and(schema::message_receipts::msg_id.eq(&update.message_id)), - ), - ) - .execute(conn)?; - // The deleted row may have been the chat's preview. - recompute_chat_preview(conn, device_id, &chat)?; - cs.chats = true; - cs.message_chats.insert(chat); - Ok(()) - } - _ => Ok(()), - } -} - -fn apply_inbound( - conn: &mut SqliteConnection, - device_id: i32, - inbound: &InboundMessage, - cs: &mut ChangeSet, -) -> QueryResult<()> { - let info = &inbound.info; - let wire = info.source.chat.to_string(); - let chat = crate::lid::route_chat_key(conn, device_id, &wire, cs)?; - if chat != wire { - cs.message_chats.insert(wire); - } - let sender = info.source.sender.to_string(); - let ts_ms = info.timestamp.timestamp_millis(); - - // Live push names ride on every message; keep contacts warm from them. - if !info.push_name.is_empty() && !info.source.is_from_me { - upsert_contact_push_name(conn, device_id, &sender, &info.push_name)?; - cs.contacts = true; - } - - // Same for business verified names, so display_name() can fall back to them. - if !info.source.is_from_me - && let Some(name) = info - .verified_name - .as_ref() - .and_then(|vn| vn.name.as_deref()) - && !name.is_empty() - { - upsert_contact_business_name(conn, device_id, &sender, name)?; - cs.contacts = true; - } - - match classify(&inbound.message) { - MessageOp::Store { kind, text } => { - let inserted = insert_message( - conn, - device_id, - NewMessage { - chat_jid: &chat, - msg_id: &info.id, - sender_jid: &sender, - from_me: info.source.is_from_me, - timestamp_ms: ts_ms, - kind, - text: text.as_deref(), - proto: Some(&waproto::codec::message_to_vec(&inbound.message)), - status: if info.source.is_from_me { - wa::web_message_info::Status::SERVER_ACK as i32 - } else { - wa::web_message_info::Status::DELIVERY_ACK as i32 - }, - starred: false, - overwrite: true, - }, - )?; - // A refreshed row (redelivery, PDO recovery of a placeholder that - // already counted) must not inflate the unread badge again — and a - // skipped one (revoked tombstone) must not surface its content in - // the chat preview at all. - let unread_delta = - i32::from(inserted == StoredRow::Inserted && !info.source.is_from_me); - if inserted != StoredRow::Skipped { - bump_chat( - conn, - device_id, - &chat, - ChatBump { - msg_id: &info.id, - ts_ms, - preview: text.as_deref(), - kind: Some(kind), - unread_delta, - }, - )?; - cs.chats = true; - } - cs.message_chats.insert(chat); - } - MessageOp::Reaction { target_id, emoji } => { - apply_reaction(conn, device_id, &chat, &target_id, &sender, &emoji, ts_ms)?; - cs.message_chats.insert(chat); - } - MessageOp::Edit { - target_id, - new_text, - new_kind, - new_proto, - } => { - if apply_edit( - conn, - device_id, - &chat, - &target_id, - &sender, - info.source.is_from_me, - new_text.as_deref(), - new_kind, - &new_proto, - ts_ms, - )? { - cs.chats = true; - } - cs.message_chats.insert(chat); - } - MessageOp::Revoke { - target_id, - target_from_me, - target_participant, - } => { - if apply_revoke( - conn, - device_id, - &chat, - &target_id, - target_participant.as_deref().unwrap_or(&sender), - target_from_me, - ts_ms, - )? { - cs.chats = true; - } - cs.message_chats.insert(chat); - } - MessageOp::Ignore => {} - } - Ok(()) -} - -/// Apply an edit to its target row. Monotonic on `edited_at_ms` so a replayed -/// or stale (e.g. history-sync) edit can't roll back a newer one. An edit -/// arriving before its target (offline drain reordering) materializes the -/// edited content up front — `insert_message` skips edited rows, so the -/// original's later arrival can't show pre-edit text. Returns whether the -/// chat-list preview changed. -#[allow(clippy::too_many_arguments)] -fn apply_edit( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - target_id: &str, - sender: &str, - from_me: bool, - new_text: Option<&str>, - new_kind: &str, - new_proto: &[u8], - ts_ms: i64, -) -> QueryResult { - use schema::messages::dsl; - let updated = diesel::update( - message_row(device_id, chat, target_id) - // A tombstone absorbs edits too: revoked content must not resurface. - .filter(dsl::revoked.eq(false)) - .filter(dsl::edited_at_ms.is_null().or(dsl::edited_at_ms.le(ts_ms))), - ) - .set(( - dsl::text_content.eq(new_text), - dsl::kind.eq(new_kind), - dsl::proto.eq(Some(new_proto)), - dsl::edited_at_ms.eq(Some(ts_ms)), - )) - .execute(conn)?; - if updated == 0 { - let inserted = diesel::insert_into(dsl::messages) - .values(( - dsl::device_id.eq(device_id), - dsl::chat_jid.eq(chat), - dsl::msg_id.eq(target_id), - dsl::sender_jid.eq(sender), - dsl::from_me.eq(from_me), - dsl::timestamp_ms.eq(ts_ms), - dsl::kind.eq(new_kind), - dsl::text_content.eq(new_text), - dsl::proto.eq(Some(new_proto)), - dsl::status.eq(if from_me { - wa::web_message_info::Status::SERVER_ACK as i32 - } else { - wa::web_message_info::Status::DELIVERY_ACK as i32 - }), - dsl::edited_at_ms.eq(Some(ts_ms)), - )) - // Conflict = the row exists but rejected the edit (revoked, or a - // newer edit already applied): stale, nothing to preserve. - .on_conflict_do_nothing() - .execute(conn)? - > 0; - if inserted { - // The message DID happen — the chat must exist, order by it and - // badge it exactly as if the (never-seen) original had landed. - bump_chat( - conn, - device_id, - chat, - ChatBump { - msg_id: target_id, - ts_ms, - preview: new_text, - kind: Some(new_kind), - unread_delta: i32::from(!from_me), - }, - )?; - return Ok(true); - } - return Ok(false); - } - refresh_preview_if_latest(conn, device_id, chat, target_id, new_text, Some(new_kind)) -} - -/// Tombstone the target row. A revoke arriving before its content (offline -/// drain reordering) inserts the tombstone up front, so the content's later -/// arrival can't resurrect it. Returns whether the chat-list preview changed. -fn apply_revoke( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - target_id: &str, - sender: &str, - target_from_me: bool, - ts_ms: i64, -) -> QueryResult { - use schema::messages::dsl; - let updated = diesel::update(message_row(device_id, chat, target_id)) - .set(( - dsl::revoked.eq(true), - dsl::text_content.eq(None::), - dsl::proto.eq(None::>), - )) - .execute(conn)?; - if updated == 0 { - let inserted = diesel::insert_into(dsl::messages) - .values(( - dsl::device_id.eq(device_id), - dsl::chat_jid.eq(chat), - dsl::msg_id.eq(target_id), - dsl::sender_jid.eq(sender), - dsl::from_me.eq(target_from_me), - dsl::timestamp_ms.eq(ts_ms), - dsl::kind.eq("unknown"), - dsl::revoked.eq(true), - )) - .on_conflict_do_nothing() - .execute(conn)? - > 0; - // The tombstone may be the chat's first/newest row: the chat must - // exist and order by it (the deleted message DID happen), and an - // unseen deletion still counts as unread like WA's own badge does. - if inserted { - bump_chat( - conn, - device_id, - chat, - ChatBump { - msg_id: target_id, - ts_ms, - preview: None, - kind: None, - unread_delta: i32::from(!target_from_me), - }, - )?; - return Ok(true); - } - return Ok(false); - } - refresh_preview_if_latest(conn, device_id, chat, target_id, None, None) -} - -/// When `msg_id` is the chat's most recent message, replace the denormalized -/// chat-list preview (an edit/revoke of an older message leaves it alone). -/// "Most recent" uses the same total order as `messages()` — `(timestamp_ms, -/// rowid)` — so a same-second sibling can't hijack the preview. -fn refresh_preview_if_latest( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - msg_id: &str, - preview: Option<&str>, - kind: Option<&str>, -) -> QueryResult { - use schema::messages::dsl; - let newest: Option = dsl::messages - .filter(dsl::device_id.eq(device_id).and(dsl::chat_jid.eq(chat))) - .order((dsl::timestamp_ms.desc(), dsl::rowid.desc())) - .select(dsl::msg_id) - .first(conn) - .optional()?; - if newest.as_deref() != Some(msg_id) { - return Ok(false); - } - diesel::update(chat_row(device_id, chat)) - .set(( - schema::chats::last_message_preview.eq(preview), - schema::chats::last_message_kind.eq(kind), - )) - .execute(conn)?; - Ok(true) -} - -struct ChatHead { - timestamp_ms: i64, - preview: Option, - kind: Option, -} - -fn newest_chat_head( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, -) -> QueryResult> { - use schema::messages::dsl; - let newest: Option<(i64, Option, String, bool)> = dsl::messages - .filter(dsl::device_id.eq(device_id).and(dsl::chat_jid.eq(chat))) - .order((dsl::timestamp_ms.desc(), dsl::rowid.desc())) - .select(( - dsl::timestamp_ms, - dsl::text_content, - dsl::kind, - dsl::revoked, - )) - .first(conn) - .optional()?; - Ok(newest.map(|(timestamp_ms, text, kind, revoked)| { - // A tombstone previews as nothing at all — its pre-revoke kind must - // not leak back into the chat head. - let (preview, kind) = if revoked { - (None, None) - } else { - (text, Some(kind)) - }; - ChatHead { - timestamp_ms, - preview, - kind, - } - })) -} - -/// Re-derive the chat-list preview from the newest remaining message (used -/// after deletions, where the previewed row may be gone). -/// -/// `last_message_ts` is deliberately NOT recomputed: it models the chat's -/// activity (list position), which WhatsApp keeps in place when the latest -/// message is deleted-for-me. Newest-row time is derivable via -/// `messages(chat, None, 1)` if a consumer ever needs it. -fn recompute_chat_preview( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, -) -> QueryResult<()> { - let (preview, kind) = match newest_chat_head(conn, device_id, chat)? { - Some(head) => (head.preview, head.kind), - None => (None, None), - }; - diesel::update(chat_row(device_id, chat)) - .set(( - schema::chats::last_message_preview.eq(preview), - schema::chats::last_message_kind.eq(kind), - )) - .execute(conn)?; - Ok(()) -} - -/// Re-derive the chat head when the server replaces an optimistic outgoing -/// timestamp. A deleted newer message deliberately keeps its activity time, -/// while the preview always follows the newest surviving row. -fn reconcile_chat_head_after_timestamp_change( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - old_timestamp_ms: i64, - new_timestamp_ms: i64, -) -> QueryResult { - use schema::chats::dsl as chats; - let current_head: Option = chat_row(device_id, chat) - .select(chats::last_message_ts) - .first(conn) - .optional()?; - let Some(current_head) = current_head else { - return Ok(false); - }; - let Some(head) = newest_chat_head(conn, device_id, chat)? else { - return Ok(false); - }; - let updated = if current_head != old_timestamp_ms && new_timestamp_ms < current_head { - diesel::update(chat_row(device_id, chat)) - .set(( - chats::last_message_preview.eq(head.preview), - chats::last_message_kind.eq(head.kind), - )) - .execute(conn)? - } else { - diesel::update(chat_row(device_id, chat)) - .set(( - chats::last_message_ts.eq(head.timestamp_ms), - chats::last_message_preview.eq(head.preview), - chats::last_message_kind.eq(head.kind), - )) - .execute(conn)? - }; - Ok(updated > 0) -} - -fn apply_reaction( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - target_id: &str, - sender: &str, - emoji: &str, - ts_ms: i64, -) -> QueryResult<()> { - use schema::reactions::dsl; - // Empty emoji is a removal tombstone, not a deletion: retaining its - // timestamp prevents an older history chunk from resurrecting the prior - // reaction. The read API hides these rows. - diesel::insert_into(dsl::reactions) - .values(( - dsl::device_id.eq(device_id), - dsl::chat_jid.eq(chat), - dsl::msg_id.eq(target_id), - dsl::sender_jid.eq(sender), - dsl::emoji.eq(emoji), - dsl::ts_ms.eq(ts_ms), - )) - .on_conflict_do_nothing() - .execute(conn)?; - // Latest reaction per sender wins; a stale copy (e.g. from a history - // chunk) must not replace either a newer live reaction or its tombstone. - diesel::update( - dsl::reactions.filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq(chat)) - .and(dsl::msg_id.eq(target_id)) - .and(dsl::sender_jid.eq(sender)) - .and(dsl::ts_ms.le(ts_ms)), - ), - ) - .set((dsl::emoji.eq(emoji), dsl::ts_ms.eq(ts_ms))) - .execute(conn)?; - Ok(()) -} - -fn apply_receipt( - conn: &mut SqliteConnection, - device_id: i32, - receipt: &wacore::types::events::Receipt, - cs: &mut ChangeSet, -) -> QueryResult<()> { - // Receipts are the one event that carries the peer's wire identity - // verbatim: the parser keeps the device on `chat` because the retry - // pipeline and the receipt echo need the full JID, so a companion device - // acking a DM arrives as `user:48@lid`. Rows are keyed bare. - let chat = receipt.source.chat.to_non_ad_string(); - let ts_ms = receipt.timestamp.timestamp_millis(); - - let status = match receipt.r#type { - ReceiptType::Delivered => wa::web_message_info::Status::DELIVERY_ACK as i32, - ReceiptType::Read => wa::web_message_info::Status::READ as i32, - ReceiptType::Played => wa::web_message_info::Status::PLAYED as i32, - ReceiptType::ReadSelf | ReceiptType::PlayedSelf => { - // Self receipts are LID-addressed once the peer is; the thread may - // be keyed by either identity (or split) — route to where it lives - // so the read state lands on the real rows, not a stray twin. - let wire = chat; - let chat = crate::lid::route_chat_key(conn, device_id, &wire, cs)?; - if chat != wire { - cs.message_chats.insert(wire); - } - // Read on another of our devices — up to the covered messages. - // WA read state is "read up to X": the boundary is the newest - // covered row (falling back to the receipt's own timestamp). - use schema::messages::dsl; - let covered_max: Option> = dsl::messages - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq(&chat)) - .and(dsl::msg_id.eq_any(&receipt.message_ids)), - ) - .select(diesel::dsl::max(dsl::timestamp_ms)) - .first(conn) - .optional()?; - let boundary_ms = covered_max.flatten().unwrap_or(ts_ms); - ensure_chat(conn, device_id, &chat)?; - // Fold into the monotonic read state: the watermark stops SHORT - // of the boundary instant (coverage there is keyed by the - // receipt's ids — timestamps collide at wire granularity), and - // the named ids ride along so a covered row materialized later - // stays read while an unlisted same-instant sibling still badges. - // A stale replay changes nothing and is skipped outright. - let Some(state) = advance_read_state( - conn, - device_id, - &chat, - boundary_ms - 1, - &receipt.message_ids, - )? - else { - // Cursor didn't move (chat re-read on another device), but a - // self-read still clears a manual-unread marker. - let state = read_state(conn, device_id, &chat)?; - let unread = count_unread(conn, device_id, &chat, &state)?; - let cleared = diesel::update( - chat_row(device_id, &chat) - .filter(schema::chats::unread_count.eq(UNREAD_MARKER)), - ) - .set(schema::chats::unread_count.eq(unread)) - .execute(conn)?; - if cleared > 0 { - cs.chats = true; - } - return Ok(()); - }; - let unread = count_unread(conn, device_id, &chat, &state)?; - diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::unread_count.eq(unread)) - .execute(conn)?; - cs.chats = true; - return Ok(()); - } - _ => return Ok(()), - }; - - // One read-by row per participant, not per device: a member reading on - // their phone and on Web emits one receipt each. - let user = receipt.source.sender.to_non_ad_string(); - let mut missed: Vec<&String> = Vec::new(); - for msg_id in &receipt.message_ids { - // Zero rows covers both the real PN/LID miss and a replay against a - // row already at/past the target; the alt retry stays harmless for - // the latter (advance-only) and still heals a lagging split copy. - if !advance_status(conn, device_id, &chat, msg_id, status)? { - missed.push(msg_id); - } - } - // A modern peer addresses the receipt by whichever identity it has for - // the thread — LID receipts for PN-keyed rows or vice versa. Retry the - // misses under the mapped counterpart key (WA Web's alternate-key - // fallback, `fixMsgKeysWithPnMapping`); costs one indexed lookup and only - // on the miss path, so the already-consistent case stays free. - // - // Where a message answers under the counterpart key, its receipt belongs - // there too: the satellite prune is per chat and drops receipt rows whose - // `msg_id` is absent from *that* chat, so a row left under the wire key - // would be collected as an orphan. - let mut relocated: std::collections::HashMap<&String, String> = - std::collections::HashMap::new(); - // Named by the receipt but held by no chat: the wire key is only a guess - // for these, resolved once below. - let mut unowned: Vec<&String> = Vec::new(); - // Resolved only when something actually missed, so a receipt whose messages - // all answered under the key they were addressed by pays nothing extra — - // which is the overwhelmingly common case and the one worth keeping free. - let counterpart = if missed.is_empty() || receipt.source.chat.is_group() { - None - } else { - crate::lid::counterpart_chat_key(conn, device_id, &chat)? - }; - for msg_id in missed { - if let Some(alt) = &counterpart - && advance_status(conn, device_id, alt, msg_id, status)? - { - relocated.insert(msg_id, alt.clone()); - continue; - } - // The status not advancing does not mean the row is missing: a replayed - // receipt, or one arriving behind the state already recorded, moves - // nothing under either key. Whether a message is here at all is a - // separate question from whether this receipt changed it, and only the - // first decides where — or whether — the receipt is filed. - // - // The addressed key is asked first, and separately from whether it - // still has a `chats` row: a delete can retire the chat while its - // messages await cleanup, and a receipt for one of those belongs where - // the message is, not where the thread went. - if message_exists(conn, device_id, &chat, msg_id)? { - continue; - } - if let Some(alt) = &counterpart - && message_exists(conn, device_id, alt, msg_id)? - { - relocated.insert(msg_id, alt.clone()); - } else { - unowned.push(msg_id); - } - } - if !relocated.is_empty() - && let Some(alt) = counterpart - { - cs.message_chats.insert(alt); - } - - // Both chat kinds record the per-state rows. A group needs them to say who - // has read; a 1:1 needs them because the message's own `status` keeps only - // the state it reached, not the instant it got there — which is the half - // WA Web's "Delivered hh:mm / Read hh:mm" is made of. - // - // A receipt for a message no chat holds is dropped rather than parked. The - // id is the server's, not ours, and nothing here can tell "our send has not - // been recorded yet" from "this message was deleted and its receipts swept - // with it" — and the second reading is the common one, because a peer - // receipt costs a round trip to that peer and back, so it arrives well - // after the send it answers. Parking it re-created metadata for messages a - // user had deleted, which is a worse answer than a blank time on a race - // that resolves itself: the message's own status is only ever advanced by a - // receipt that finds it, and a later one for the same message will. - for msg_id in &receipt.message_ids { - let key = match relocated.get(msg_id) { - Some(alt) => alt, - None if unowned.contains(&msg_id) => continue, - None => &chat, - }; - record_receipt(conn, device_id, key, msg_id, &user, status, ts_ms)?; - } - - cs.message_chats.insert(chat); - Ok(()) -} - -/// Move one of our messages forward to `status`, reporting whether it moved. -/// -/// Peer receipts only ever advance the delivery state of our own messages, and -/// never backwards — so a replay, or one arriving behind the state already -/// recorded, moves nothing and says so. -fn advance_status( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - msg_id: &str, - status: i32, -) -> QueryResult { - let updated = diesel::update( - message_row(device_id, chat, msg_id).filter( - schema::messages::from_me - .eq(true) - .and(schema::messages::status.lt(status)), - ), - ) - .set(schema::messages::status.eq(status)) - .execute(conn)?; - Ok(updated > 0) -} - -/// Whether this device stores an outgoing message with this id in this chat. -fn message_exists( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - msg_id: &str, -) -> QueryResult { - diesel::select(diesel::dsl::exists( - message_row(device_id, chat, msg_id).filter(schema::messages::from_me.eq(true)), - )) - .get_result(conn) -} - -/// Record that `user` reached `status` on one message, at `ts_ms`. -/// -/// Keeps the earliest instant for a state rather than the first one processed. -/// A replay is a duplicate rather than a new event, and receipts do not arrive -/// in time order: an offline queue drains after the live socket, so a peer -/// device's delayed report can land behind a later one for the same state. -/// Arrival order would then decide what message info shows, which is the same -/// reason the alias merge resolves its collisions by `MIN(ts_ms)`. -fn record_receipt( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - msg_id: &str, - user: &str, - status: i32, - ts_ms: i64, -) -> QueryResult<()> { - use schema::message_receipts::dsl; - let row = || { - dsl::message_receipts.filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq(chat)) - .and(dsl::msg_id.eq(msg_id)) - .and(dsl::user_jid.eq(user)) - .and(dsl::receipt_type.eq(status)), - ) - }; - let inserted = diesel::insert_into(dsl::message_receipts) - .values(( - dsl::device_id.eq(device_id), - dsl::chat_jid.eq(chat), - dsl::msg_id.eq(msg_id), - dsl::user_jid.eq(user), - dsl::receipt_type.eq(status), - dsl::ts_ms.eq(ts_ms), - )) - .on_conflict_do_nothing() - .execute(conn)?; - // Only a conflict leaves an instant to reconsider: a row this call created - // already holds `ts_ms`, and the first report of a state is the common - // case on a path that runs for every receipt. - if inserted == 0 { - diesel::update(row().filter(dsl::ts_ms.gt(ts_ms))) - .set(dsl::ts_ms.eq(ts_ms)) - .execute(conn)?; - } - Ok(()) -} - -/// Which outgoing row a server ack belongs to, if one can be named. -/// -/// [`NotYet`](Self::NotYet) and [`Ambiguous`](Self::Ambiguous) are both "no row -/// applied", but they must not be treated alike: only the first is answerable -/// by waiting. Deferring an ambiguous ack would hand it to whichever row next -/// claims that id — turning a deliberate refusal into a delayed mis-apply. -enum AckTarget { - Resolved { - chat: String, - timestamp_ms: i64, - }, - /// No outgoing row with this id yet. Carries the storage key the ack named, - /// when it named one, so a deferral can be held against that chat instead - /// of against the id alone. - NotYet { - chat: Option, - }, - Ambiguous, -} - -fn resolve_server_ack_message( - conn: &mut SqliteConnection, - device_id: i32, - ack: &wacore::types::events::ServerAck, - cs: &mut ChangeSet, -) -> QueryResult { - use schema::messages::dsl; - if let Some(from) = &ack.from { - let wire = from.to_string(); - let chat = crate::lid::route_chat_key(conn, device_id, &wire, cs)?; - let timestamp_ms: Option = message_row(device_id, &chat, &ack.id) - .filter(dsl::from_me.eq(true)) - .select(dsl::timestamp_ms) - .first(conn) - .optional()?; - if let Some(timestamp_ms) = timestamp_ms { - return Ok(AckTarget::Resolved { chat, timestamp_ms }); - } - // The row may sit under the peer's other identity, so retry across the - // PN/LID pair — but ONLY that pair. Message ids are sender-chosen and - // unique within a chat, so widening this to every chat on the device - // would let a named ack land on an unrelated thread that happens to - // reuse the id. - let keys = crate::lid::chat_key_candidates(conn, device_id, &wire)?; - let aliased: Option<(String, i64)> = dsl::messages - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::chat_jid.eq_any(keys)) - .and(dsl::msg_id.eq(&ack.id)) - .and(dsl::from_me.eq(true)), - ) - .select((dsl::chat_jid, dsl::timestamp_ms)) - .first(conn) - .optional()?; - return Ok(match aliased { - Some((chat, timestamp_ms)) => AckTarget::Resolved { chat, timestamp_ms }, - None => AckTarget::NotYet { chat: Some(chat) }, - }); - } - - // Only a chatless ack falls back to the whole device, and then the id is - // safe only when it names exactly one outgoing row. - let matches: Vec<(String, i64)> = dsl::messages - .filter( - dsl::device_id - .eq(device_id) - .and(dsl::msg_id.eq(&ack.id)) - .and(dsl::from_me.eq(true)), - ) - .select((dsl::chat_jid, dsl::timestamp_ms)) - .limit(2) - .load(conn)?; - match <[(String, i64); 1]>::try_from(matches) { - Ok([(chat, timestamp_ms)]) => Ok(AckTarget::Resolved { chat, timestamp_ms }), - Err(matches) if matches.is_empty() => Ok(AckTarget::NotYet { chat: None }), - Err(_) => { - warn!( - target: "ChatStore/Ack", - "Ignoring ambiguous message ack for reused id {}", - ack.id - ); - Ok(AckTarget::Ambiguous) - } - } -} - -/// How long an unmatched message ack waits for its outgoing row, and how many -/// may wait at once. Both are generous relative to the window they cover (a -/// local enqueue losing to a network round trip) and small enough that a -/// pathological stream of unmatchable ids cannot grow the writer's footprint. -const DEFERRED_ACK_TTL_MS: i64 = 60_000; -const DEFERRED_ACK_CAP: usize = 64; - -/// Message-class acks that arrived before their outgoing row existed. -/// -/// `Event::ServerAck` is dispatched synchronously on the socket-read path, -/// while `send_message` returns at the stanza write. A host that records its -/// outgoing message *after* the send resolves — the safe order, since -/// recording first leaves a forever-pending ghost row when the send fails and -/// the store has no row delete — therefore races the ack. The window is narrow, -/// needing the local enqueue to lose to a full round trip, but the loss used to -/// be silent and permanent: the row kept its `pending` clock until some -/// delivery receipt happened to lift it (never, for an offline recipient) and -/// never picked up the server's authoritative send timestamp. -/// -/// This is the same materialize-later shape the store already uses for -/// out-of-order edits and revokes, minus the placeholder row: an ack carries no -/// content, so there is nothing to show until the real insert arrives. -#[derive(Default, Clone)] -pub(crate) struct DeferredAcks { - /// Oldest first — pushes append, so the queue is sorted by age and expiry - /// is a prefix drain. - entries: std::collections::VecDeque, - /// Everything [`defer`](Self::defer) added since [`begin_batch`], kept even - /// after `take_matching` consumes it. - /// - /// A batch's two kinds of mutation roll back in opposite directions. A - /// consumption must be undone — the insert that took the ack did not - /// survive, so the ack is still owed a row. An addition must NOT be undone: - /// its `ServerAck` event is already off the writer channel and there is no - /// redelivery for it, so this queue is the only remaining record. Losing it - /// is precisely the silent, permanent drop the queue exists to prevent. - /// - /// [`begin_batch`]: Self::begin_batch - added_this_batch: Vec, -} - -#[derive(Clone)] -struct DeferredAck { - deferred_at_ms: i64, - /// Storage key the ack named, when it named one. Message ids are - /// sender-chosen and only unique within a chat, so an ack that names its - /// chat must only be handed to an insert into that same chat — otherwise a - /// host reusing one id across two threads could see chat A's ack land on - /// chat B's row. `None` (the server omitted the chat) matches on the id - /// alone, which is the same basis its own resolution falls back to. - /// - /// That `None` case stays order-dependent, as the undeferred chatless path - /// always has been: it resolves against the rows that exist when it runs, - /// so a host that reuses one id across two chats can have the first insert - /// take an ack the second would have made ambiguous. Closing that would - /// mean holding every ack to the end of the batch, trading the writer's - /// in-order application for a case that needs the host to break id - /// uniqueness in the first place. - chat: Option, - ack: wacore::types::events::ServerAck, -} - -impl DeferredAcks { - fn expire(&mut self, now_ms: i64) { - while let Some(entry) = self.entries.front() { - if now_ms.saturating_sub(entry.deferred_at_ms) < DEFERRED_ACK_TTL_MS { - break; - } - warn!( - target: "ChatStore/Ack", - "Dropping unmatched message ack for {}: no outgoing row appeared within {}s", - entry.ack.id, - DEFERRED_ACK_TTL_MS / 1000 - ); - self.entries.pop_front(); - } - } - - /// Append within the cap, evicting the oldest waiter to make room. - fn push_bounded(&mut self, entry: DeferredAck) { - if self.entries.len() >= DEFERRED_ACK_CAP - && let Some(evicted) = self.entries.pop_front() - { - warn!( - target: "ChatStore/Ack", - "Dropping unmatched message ack for {}: {DEFERRED_ACK_CAP} acks already waiting", - evicted.ack.id - ); - } - self.entries.push_back(entry); - } - - /// Open a writer batch: the previous batch's additions are settled and no - /// longer need replaying. - fn begin_batch(&mut self) { - self.added_this_batch.clear(); - } - - /// Fold a batch that did not commit back onto the state it started from. - /// - /// The pre-batch queue is the truth for consumptions — the inserts that - /// took those acks rolled back, so they are still owed rows. The batch's - /// additions ride along on top, because nothing will deliver them again. - fn rolled_back(self, mut pre_batch: Self) -> Self { - for entry in self.added_this_batch { - pre_batch.push_bounded(entry); - } - pre_batch - } - - fn defer(&mut self, ack: &wacore::types::events::ServerAck, chat: Option, now_ms: i64) { - self.expire(now_ms); - let entry = DeferredAck { - deferred_at_ms: now_ms, - chat, - ack: ack.clone(), - }; - self.added_this_batch.push(entry.clone()); - self.push_bounded(entry); - } - - fn take_matching( - &mut self, - msg_id: &str, - chat: &str, - now_ms: i64, - ) -> Option { - self.expire(now_ms); - let at = self.entries.iter().position(|entry| { - entry.ack.id == msg_id && entry.chat.as_deref().is_none_or(|named| named == chat) - })?; - self.entries.remove(at).map(|entry| entry.ack) - } -} - -/// What became of a server ack, so the caller knows whether anything is left to -/// hold on to. -enum AckApplied { - /// Applied to a row, or deliberately dropped — nothing left to hold. - Settled, - /// The send is not recorded yet. Carries the storage key the ack named, to - /// hold the deferral against. - Deferrable(Option), -} - -fn apply_server_ack( - conn: &mut SqliteConnection, - device_id: i32, - ack: &wacore::types::events::ServerAck, - cs: &mut ChangeSet, -) -> QueryResult { - // Acks cover every stanza class; only message acks map to a stored row. - if ack.class.as_deref() != Some("message") { - return Ok(AckApplied::Settled); - } - use schema::messages::dsl; - let (chat, old_timestamp_ms) = match resolve_server_ack_message(conn, device_id, ack, cs)? { - AckTarget::Resolved { chat, timestamp_ms } => (chat, timestamp_ms), - // Answerable by waiting: the send may just not be recorded yet. - AckTarget::NotYet { chat } => return Ok(AckApplied::Deferrable(chat)), - // Not answerable by waiting, and dangerous to hold — report it settled - // so the caller drops it instead of arming it for the next row that - // reuses the id. - AckTarget::Ambiguous => return Ok(AckApplied::Settled), - }; - let target = message_row(device_id, &chat, &ack.id).filter(dsl::from_me.eq(true)); - let status_updated = if ack.error.is_some() { - // Nack: the server rejected the send. Only a still-pending row fails — - // the server emits one ack per stanza, so a row past PENDING already - // got its positive answer and a stray nack must not regress it. - diesel::update(target.filter(dsl::status.eq(wa::web_message_info::Status::PENDING as i32))) - .set(dsl::status.eq(wa::web_message_info::Status::ERROR as i32)) - .execute(conn)? - > 0 - } else { - diesel::update( - target.filter(dsl::status.lt(wa::web_message_info::Status::SERVER_ACK as i32)), - ) - .set(dsl::status.eq(wa::web_message_info::Status::SERVER_ACK as i32)) - .execute(conn)? - > 0 - }; - // A positive message ack's `t` is the server's authoritative send clock. - // Apply it independently of the status transition: a delivery/read receipt - // may have advanced the row before the ack event reaches this writer. - let server_timestamp_ms = ack - .timestamp - .filter(|_| ack.error.is_none()) - .map(|timestamp| timestamp.timestamp_millis()); - let timestamp_updated = if let Some(timestamp_ms) = server_timestamp_ms { - diesel::update( - message_row(device_id, &chat, &ack.id) - .filter(dsl::from_me.eq(true)) - .filter(dsl::timestamp_ms.ne(timestamp_ms)), - ) - .set(dsl::timestamp_ms.eq(timestamp_ms)) - .execute(conn)? - > 0 - } else { - false - }; - if timestamp_updated - && let Some(timestamp_ms) = server_timestamp_ms - && reconcile_chat_head_after_timestamp_change( - conn, - device_id, - &chat, - old_timestamp_ms, - timestamp_ms, - )? - { - cs.chats = true; - } - if status_updated || timestamp_updated { - // Resolve the chat from the row itself: the ack's `from` is the wire - // identity, which may not be the key the row is stored under (PN/LID - // aliasing). Emit both so consumers keyed by either get invalidated. - cs.message_chats.insert(chat); - if let Some(from) = &ack.from { - cs.message_chats.insert(from.to_string()); - } - } - Ok(AckApplied::Settled) -} - -fn apply_history_sync( - conn: &mut SqliteConnection, - device_id: i32, - lazy: &wacore::types::events::LazyHistorySync, - cs: &mut ChangeSet, -) -> QueryResult<()> { - let mut stream = lazy.stream(); - loop { - let conv = match stream.next_conversation() { - Ok(Some(conv)) => conv, - Ok(None) => break, - Err(e) => { - // Framing/zlib failure: the stream position is gone, the rest - // of this chunk is unreadable (per-conversation decode errors - // are skipped inside the stream, not surfaced here). - warn!("chat-store: history sync chunk framing broken, aborting chunk: {e}"); - return Ok(()); - } - }; - apply_history_conversation(conn, device_id, &conv, cs)?; - } - if stream.skipped_conversations() > 0 { - warn!( - "chat-store: history sync skipped {} undecodable conversation(s)", - stream.skipped_conversations() - ); - } - match stream.remainder() { - Ok(rest) => { - for pushname in &rest.pushnames { - if let (Some(jid), Some(name)) = (&pushname.id, &pushname.pushname) { - upsert_contact_push_name(conn, device_id, jid, name)?; - cs.contacts = true; - } - } - } - Err(e) => warn!("chat-store: history sync remainder unreadable: {e}"), - } - Ok(()) -} - -fn apply_history_conversation( - conn: &mut SqliteConnection, - device_id: i32, - conv: &wa::Conversation, - cs: &mut ChangeSet, -) -> QueryResult<()> { - let chat = &crate::lid::route_chat_key(conn, device_id, conv.id.as_str(), cs)?; - let last_ts_ms = conv - .conversation_timestamp - .map(|s| (s as i64).saturating_mul(1000)) - .unwrap_or(0); - - { - use schema::chats::dsl; - let name = conv.name.as_deref().or(conv.display_name.as_deref()); - diesel::insert_into(dsl::chats) - .values(( - dsl::device_id.eq(device_id), - dsl::jid.eq(chat), - dsl::name.eq(name), - dsl::last_message_ts.eq(last_ts_ms), - dsl::unread_count.eq(conv.unread_count.unwrap_or(0) as i32), - // Wire values are unix SECONDS; the columns (and the live - // app-state paths) are milliseconds. - dsl::pinned_at.eq(conv - .pinned - .map(|p| (p as i64).saturating_mul(1000)) - .filter(|&p| p > 0)), - dsl::muted_until.eq(conv - .mute_end_time - .map(|m| (m as i64).saturating_mul(1000)) - .filter(|&m| m > 0)), - dsl::archived.eq(conv.archived.unwrap_or(false)), - dsl::ephemeral_expiration.eq(conv.ephemeral_expiration.map(|e| e as i32)), - )) - .on_conflict((dsl::device_id, dsl::jid)) - .do_update() - // Live rows already track unread/mute/pin; history only refreshes - // identity + activity floor. - .set(( - dsl::name.eq(name), - dsl::last_message_ts.eq(diesel::dsl::sql::( - "MAX(last_message_ts, excluded.last_message_ts)", - )), - )) - .execute(conn)?; - } - - for hist_msg in &conv.messages { - let Some(wmi) = hist_msg.message.as_option() else { - continue; - }; - apply_history_message(conn, device_id, chat, wmi, cs)?; - } - // Backfill the denormalized preview from the newest materialized row, so a - // freshly-paired client's chat list isn't blank until live traffic. - recompute_chat_preview(conn, device_id, chat)?; - cs.chats = true; - cs.message_chats.insert(chat.to_string()); - Ok(()) -} - -fn apply_history_message( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - wmi: &wa::WebMessageInfo, - cs: &mut ChangeSet, -) -> QueryResult<()> { - let Some(key) = wmi.key.as_option() else { - return Ok(()); - }; - let Some(msg_id) = key.id.as_deref() else { - return Ok(()); - }; - let from_me = key.from_me.unwrap_or(false); - let sender = wmi - .participant - .as_deref() - .or(key.participant.as_deref()) - .unwrap_or(if from_me { "" } else { chat }); - let ts_ms = wmi - .message_timestamp - .map(|s| (s as i64).saturating_mul(1000)) - .unwrap_or(0); - - if let Some(name) = wmi.push_name.as_deref() - && !name.is_empty() - && !from_me - && !sender.is_empty() - { - upsert_contact_push_name(conn, device_id, sender, name)?; - cs.contacts = true; - } - - if let Some(message) = wmi.message.as_option() { - match classify(message) { - MessageOp::Store { kind, text } => { - let _ = insert_message( - conn, - device_id, - NewMessage { - chat_jid: chat, - msg_id, - sender_jid: sender, - from_me, - timestamp_ms: ts_ms, - kind, - text: text.as_deref(), - proto: Some(&waproto::codec::message_to_vec(message)), - status: wmi - .status - .map(|s| s as i32) - .unwrap_or(wa::web_message_info::Status::PENDING as i32), - starred: wmi.starred.unwrap_or(false), - // History is the stale copy: live rows win. - overwrite: false, - }, - )?; - } - MessageOp::Reaction { target_id, emoji } => { - apply_reaction(conn, device_id, chat, &target_id, sender, &emoji, ts_ms)?; - } - MessageOp::Edit { - target_id, - new_text, - new_kind, - new_proto, - } => { - if apply_edit( - conn, - device_id, - chat, - &target_id, - sender, - from_me, - new_text.as_deref(), - new_kind, - &new_proto, - ts_ms, - )? { - cs.chats = true; - } - } - MessageOp::Revoke { - target_id, - target_from_me, - target_participant, - } => { - if apply_revoke( - conn, - device_id, - chat, - &target_id, - target_participant.as_deref().unwrap_or(sender), - target_from_me, - ts_ms, - )? { - cs.chats = true; - } - } - MessageOp::Ignore => {} - } - } - - // Reactions the server aggregated onto the target message. - for reaction in &wmi.reactions { - let Some(text) = reaction.text.as_deref() else { - continue; - }; - let reactor = reaction - .key - .as_option() - .and_then(|k| { - if k.from_me.unwrap_or(false) { - Some("") - } else { - k.participant.as_deref().or(k.remote_jid.as_deref()) - } - }) - .unwrap_or(""); - let reaction_ts = reaction.sender_timestamp_ms.unwrap_or(ts_ms); - apply_reaction(conn, device_id, chat, msg_id, reactor, text, reaction_ts)?; - } - Ok(()) -} - -struct NewMessage<'a> { - chat_jid: &'a str, - msg_id: &'a str, - sender_jid: &'a str, - from_me: bool, - timestamp_ms: i64, - kind: &'a str, - text: Option<&'a str>, - proto: Option<&'a [u8]>, - status: i32, - starred: bool, - /// Live redeliveries refresh content in place (PDO recovery replaces an - /// `undecryptable` placeholder); history-sync copies never clobber live rows. - overwrite: bool, -} - -/// What actually happened to the row, so callers can gate side effects -/// (unread counting, chat-preview bumps) on it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StoredRow { - /// A new row was inserted. - Inserted, - /// The id existed; its content was refreshed in place (`overwrite`). - Refreshed, - /// The id existed and was left untouched (history duplicate, or a revoked - /// tombstone that a redelivery must not resurrect or re-surface). - Skipped, -} - -/// A refresh never touches `revoked` (a tombstone outranks any stale -/// redelivery) and never crosses senders: message ids are SENDER-chosen, so a -/// same-id row from a different sender must not rewrite the original's content -/// (adversarial id reuse would otherwise alter someone else's message in the -/// local history). Both cases report [`StoredRow::Skipped`]. -fn insert_message( - conn: &mut SqliteConnection, - device_id: i32, - new: NewMessage<'_>, -) -> QueryResult { - use schema::messages::dsl; - let values = ( - dsl::device_id.eq(device_id), - dsl::chat_jid.eq(new.chat_jid), - dsl::msg_id.eq(new.msg_id), - dsl::sender_jid.eq(new.sender_jid), - dsl::from_me.eq(new.from_me), - dsl::timestamp_ms.eq(new.timestamp_ms), - dsl::kind.eq(new.kind), - dsl::text_content.eq(new.text), - dsl::proto.eq(new.proto), - dsl::status.eq(new.status), - dsl::starred.eq(new.starred), - ); - let inserted = diesel::insert_into(dsl::messages) - .values(values) - .on_conflict_do_nothing() - .execute(conn)? - > 0; - if inserted { - return Ok(StoredRow::Inserted); - } - if new.overwrite { - let refreshed = diesel::update( - message_row(device_id, new.chat_jid, new.msg_id) - .filter(dsl::revoked.eq(false)) - .filter(dsl::sender_jid.eq(new.sender_jid)) - // A redelivery carries the PRE-edit original; an edited row - // must keep its newer content. - .filter(dsl::edited_at_ms.is_null()), - ) - .set(( - dsl::kind.eq(new.kind), - dsl::text_content.eq(new.text), - dsl::proto.eq(new.proto), - )) - .execute(conn)?; - if refreshed > 0 { - return Ok(StoredRow::Refreshed); - } - } - Ok(StoredRow::Skipped) -} - -/// Refresh a chat's activity row for a message at `ts_ms`: creates the row if -/// missing, advances ordering/preview only for newer messages, and bumps the -/// unread counter by `unread_delta` (unless manually marked unread). -/// One message's contribution to its chat's denormalized row. -struct ChatBump<'a> { - msg_id: &'a str, - ts_ms: i64, - preview: Option<&'a str>, - kind: Option<&'a str>, - unread_delta: i32, -} - -fn bump_chat( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - bump: ChatBump<'_>, -) -> QueryResult<()> { - use schema::chats::dsl; - ensure_chat(conn, device_id, chat)?; - // Ordering timestamp is monotonic on its own... - diesel::update(chat_row(device_id, chat).filter(dsl::last_message_ts.le(bump.ts_ms))) - .set(dsl::last_message_ts.eq(bump.ts_ms)) - .execute(conn)?; - // ...but the preview belongs to the newest row by the FULL (timestamp_ms, - // msg_id) order — a same-millisecond sibling applied later must not win. - refresh_preview_if_latest(conn, device_id, chat, bump.msg_id, bump.preview, bump.kind)?; - if bump.unread_delta != 0 { - // An old row materialized late (offline drain) that a read already - // covered must not badge. - let state = read_state(conn, device_id, chat)?; - if !state.covers(bump.ts_ms, bump.msg_id) { - diesel::update(chat_row(device_id, chat).filter(dsl::unread_count.ge(0))) - .set(dsl::unread_count.eq(dsl::unread_count + bump.unread_delta)) - .execute(conn)?; - } - } - Ok(()) -} - -fn ensure_chat(conn: &mut SqliteConnection, device_id: i32, chat: &str) -> QueryResult<()> { - use schema::chats::dsl; - diesel::insert_into(dsl::chats) - .values((dsl::device_id.eq(device_id), dsl::jid.eq(chat))) - .on_conflict_do_nothing() - .execute(conn)?; - Ok(()) -} - -/// Union a split pair's chat rows into `dest` and drop `src` (the message -/// rows have already moved). Activity and preview re-derive from the merged -/// messages; the self-read state is the union of both sides so neither -/// side's covered messages re-badge; sticky user prefs (pin/mute/archive, -/// name, ephemeral) keep dest's value and fall back to src's. A manual-unread -/// marker on either side survives; otherwise the badge is recounted. -pub(crate) fn merge_chat_metadata( - conn: &mut SqliteConnection, - device_id: i32, - src: &str, - dest: &str, -) -> QueryResult<()> { - use schema::chats::dsl; - type PrefRow = ( - i64, - i32, - Option, - Option, - bool, - Option, - Option, - ); - let prefs = |conn: &mut SqliteConnection, key: &str| -> QueryResult> { - chat_row(device_id, key) - .select(( - dsl::last_message_ts, - dsl::unread_count, - dsl::pinned_at, - dsl::muted_until, - dsl::archived, - dsl::ephemeral_expiration, - dsl::name, - )) - .first(conn) - .optional() - }; - let Some(src_row) = prefs(conn, src)? else { - return Ok(()); - }; - let src_state = read_state(conn, device_id, src)?; - ensure_chat(conn, device_id, dest)?; - let dest_row = prefs(conn, dest)?.unwrap_or((0, 0, None, None, false, None, None)); - let dest_state = read_state(conn, device_id, dest)?; - - let mut merged = ReadState { - watermark_ms: src_state.watermark_ms.max(dest_state.watermark_ms), - extra_ids: dest_state.extra_ids, - }; - for id in src_state.extra_ids { - if !merged.extra_ids.contains(&id) { - merged.extra_ids.push(id); - } - } - if merged.extra_ids.len() > READ_EXTRA_IDS_CAP { - let overflow = merged.extra_ids.len() - READ_EXTRA_IDS_CAP; - merged.extra_ids.drain(..overflow); - } - let ids_json = (!merged.extra_ids.is_empty()) - .then(|| serde_json::to_string(&merged.extra_ids).ok()) - .flatten(); - - let unread = if src_row.1 == UNREAD_MARKER || dest_row.1 == UNREAD_MARKER { - UNREAD_MARKER - } else { - count_unread(conn, device_id, dest, &merged)? - }; - diesel::update(chat_row(device_id, dest)) - .set(( - dsl::last_message_ts.eq(src_row.0.max(dest_row.0)), - dsl::unread_count.eq(unread), - dsl::pinned_at.eq(dest_row.2.or(src_row.2)), - dsl::muted_until.eq(dest_row.3.or(src_row.3)), - dsl::archived.eq(dest_row.4 || src_row.4), - dsl::ephemeral_expiration.eq(dest_row.5.or(src_row.5)), - dsl::name.eq(dest_row.6.or(src_row.6)), - dsl::read_boundary_ms.eq(merged.watermark_ms), - dsl::read_boundary_ids.eq(ids_json), - )) - .execute(conn)?; - recompute_chat_preview(conn, device_id, dest)?; - diesel::delete(chat_row(device_id, src)).execute(conn)?; - Ok(()) -} - -/// Contacts are keyed by the peer's bare identity, the canonical form -/// [`ChatStore::contact`] looks up. Message senders keep their device by -/// design (a peer texting from WhatsApp Web is `user:48@lid`), so writing the -/// sender verbatim would file the name under a key nothing ever reads. -fn contact_key(jid: &str) -> Cow<'_, str> { - match jid.parse::() { - // Bare already renders identically; only pay the allocation otherwise. - Ok(parsed) if parsed.device != 0 || parsed.agent != 0 => { - Cow::Owned(parsed.to_non_ad_string()) - } - _ => Cow::Borrowed(jid), - } -} - -fn upsert_contact_push_name( - conn: &mut SqliteConnection, - device_id: i32, - jid: &str, - push_name: &str, -) -> QueryResult<()> { - use schema::contacts::dsl; - let jid = contact_key(jid); - diesel::insert_into(dsl::contacts) - .values(( - dsl::device_id.eq(device_id), - dsl::jid.eq(&jid), - dsl::push_name.eq(push_name), - )) - .on_conflict((dsl::device_id, dsl::jid)) - .do_update() - .set(dsl::push_name.eq(push_name)) - .execute(conn)?; - Ok(()) -} - -fn upsert_contact_business_name( - conn: &mut SqliteConnection, - device_id: i32, - jid: &str, - business_name: &str, -) -> QueryResult<()> { - use schema::contacts::dsl; - let jid = contact_key(jid); - diesel::insert_into(dsl::contacts) - .values(( - dsl::device_id.eq(device_id), - dsl::jid.eq(&jid), - dsl::business_name.eq(business_name), - )) - .on_conflict((dsl::device_id, dsl::jid)) - .do_update() - .set(dsl::business_name.eq(business_name)) - .execute(conn)?; - Ok(()) -} - -fn upsert_contact_names( - conn: &mut SqliteConnection, - device_id: i32, - jid: &str, - full_name: Option<&str>, - first_name: Option<&str>, -) -> QueryResult<()> { - use schema::contacts::dsl; - let jid = contact_key(jid); - diesel::insert_into(dsl::contacts) - .values(( - dsl::device_id.eq(device_id), - dsl::jid.eq(&jid), - dsl::full_name.eq(full_name), - dsl::first_name.eq(first_name), - )) - .on_conflict((dsl::device_id, dsl::jid)) - .do_update() - .set((dsl::full_name.eq(full_name), dsl::first_name.eq(first_name))) - .execute(conn)?; - Ok(()) -} - -/// Delete a chat's message rows (and their reactions/receipts). With -/// `delete_starred = false`, starred messages and their satellites survive. -fn delete_chat_rows( - conn: &mut SqliteConnection, - device_id: i32, - chat: &str, - delete_starred: bool, - bound: Option<&RangeBound>, -) -> QueryResult<()> { - use schema::messages::dsl as m; - // A ranged action only covers messages up to its boundary; rows we - // materialized after it (live/offline traffic) survive. With a keyed - // boundary, same-second siblings the action does not name survive too. - match bound { - None => { - let mut query = diesel::delete( - m::messages.filter(m::device_id.eq(device_id).and(m::chat_jid.eq(chat))), - ) - .into_boxed(); - if !delete_starred { - query = query.filter(m::starred.eq(false)); - } - query.execute(conn)?; - } - Some(bound) => { - let mut query = diesel::delete( - m::messages.filter(m::device_id.eq(device_id).and(m::chat_jid.eq(chat))), - ) - .into_boxed(); - if !delete_starred { - query = query.filter(m::starred.eq(false)); - } - match &bound.keys { - None => { - query = query.filter(m::timestamp_ms.le(bound.second_end_ms)); - query.execute(conn)?; - } - Some(keys) => { - // Everything strictly before the boundary second... - query = query.filter(m::timestamp_ms.lt(bound.second_start_ms)); - query.execute(conn)?; - // ...plus the boundary rows the action names explicitly. - let mut keyed = diesel::delete( - m::messages.filter(m::device_id.eq(device_id).and(m::chat_jid.eq(chat))), - ) - .into_boxed(); - if !delete_starred { - keyed = keyed.filter(m::starred.eq(false)); - } - keyed - .filter(m::timestamp_ms.le(bound.second_end_ms)) - .filter(m::msg_id.eq_any(keys)) - .execute(conn)?; - } - } - } - } - // Satellites of messages that no longer exist. - diesel::sql_query( - "DELETE FROM reactions WHERE device_id = ? AND chat_jid = ? AND msg_id NOT IN \ - (SELECT msg_id FROM messages WHERE device_id = ? AND chat_jid = ?)", - ) - .bind::(device_id) - .bind::(chat) - .bind::(device_id) - .bind::(chat) - .execute(conn)?; - diesel::sql_query( - "DELETE FROM message_receipts WHERE device_id = ? AND chat_jid = ? AND msg_id NOT IN \ - (SELECT msg_id FROM messages WHERE device_id = ? AND chat_jid = ?)", - ) - .bind::(device_id) - .bind::(chat) - .bind::(device_id) - .bind::(chat) - .execute(conn)?; - Ok(()) -} - -fn remaining_messages(conn: &mut SqliteConnection, device_id: i32, chat: &str) -> QueryResult { - use schema::messages::dsl; - dsl::messages - .filter(dsl::device_id.eq(device_id).and(dsl::chat_jid.eq(chat))) - .count() - .get_result(conn) -} - -type ChatRowFilter<'a> = diesel::dsl::Filter< - schema::chats::table, - diesel::dsl::And< - diesel::dsl::Eq, - diesel::dsl::Eq, - >, ->; - -fn chat_row(device_id: i32, chat: &str) -> ChatRowFilter<'_> { - schema::chats::table.filter( - schema::chats::device_id - .eq(device_id) - .and(schema::chats::jid.eq(chat)), - ) -} - -pub(crate) type MessageRowFilter<'a> = diesel::dsl::Filter< - schema::messages::table, - diesel::dsl::And< - diesel::dsl::And< - diesel::dsl::Eq, - diesel::dsl::Eq, - >, - diesel::dsl::Eq, - >, ->; - -pub(crate) fn message_row<'a>( - device_id: i32, - chat: &'a str, - msg_id: &'a str, -) -> MessageRowFilter<'a> { - schema::messages::table.filter( - schema::messages::device_id - .eq(device_id) - .and(schema::messages::chat_jid.eq(chat)) - .and(schema::messages::msg_id.eq(msg_id)), - ) -} - -#[cfg(test)] -mod deferred_ack_tests { - use super::*; - - fn ack(id: &str) -> wacore::types::events::ServerAck { - wacore::types::events::ServerAck::builder() - .id(id.to_string()) - .class("message".to_string()) - .build() - } - - const CHAT: &str = "559900000001@s.whatsapp.net"; - const OTHER: &str = "559900000002@s.whatsapp.net"; - - #[test] - fn takes_only_its_own_id() { - let mut acks = DeferredAcks::default(); - acks.defer(&ack("A"), None, 0); - acks.defer(&ack("B"), None, 0); - - assert!(acks.take_matching("C", CHAT, 0).is_none()); - assert_eq!(acks.take_matching("B", CHAT, 0).unwrap().id, "B"); - // Consumed, not merely read. - assert!(acks.take_matching("B", CHAT, 0).is_none()); - assert_eq!(acks.take_matching("A", CHAT, 0).unwrap().id, "A"); - } - - /// Message ids are sender-chosen and unique only within a chat, so an ack - /// that named its chat must not be handed to an insert into another one. - #[test] - fn a_chat_scoped_ack_ignores_the_same_id_elsewhere() { - let mut acks = DeferredAcks::default(); - acks.defer(&ack("OUT-DUP"), Some(CHAT.to_string()), 0); - - assert!( - acks.take_matching("OUT-DUP", OTHER, 0).is_none(), - "another chat's insert must not consume it" - ); - assert!(acks.take_matching("OUT-DUP", CHAT, 0).is_some()); - } - - /// An ack the server sent without a chat resolves on the id alone, so it - /// matches whichever chat records that id. - #[test] - fn a_chatless_ack_matches_any_chat() { - let mut acks = DeferredAcks::default(); - acks.defer(&ack("OUT-ANY"), None, 0); - assert!(acks.take_matching("OUT-ANY", OTHER, 0).is_some()); - } - - #[test] - fn drops_entries_past_the_ttl() { - let mut acks = DeferredAcks::default(); - acks.defer(&ack("STALE"), None, 0); - - assert!( - acks.take_matching("STALE", CHAT, DEFERRED_ACK_TTL_MS) - .is_none() - ); - // One millisecond inside the window still matches. - acks.defer(&ack("FRESH"), None, 0); - assert!( - acks.take_matching("FRESH", CHAT, DEFERRED_ACK_TTL_MS - 1) - .is_some() - ); - } - - #[test] - fn evicts_the_oldest_at_capacity() { - let mut acks = DeferredAcks::default(); - for i in 0..DEFERRED_ACK_CAP + 1 { - acks.defer(&ack(&format!("ACK-{i}")), None, 0); - } - assert!( - acks.take_matching("ACK-0", CHAT, 0).is_none(), - "the oldest makes room" - ); - assert!( - acks.take_matching(&format!("ACK-{DEFERRED_ACK_CAP}"), CHAT, 0) - .is_some() - ); - } - - /// A rolled-back batch undoes what it consumed: the insert that took the - /// ack did not survive, so the ack is still owed a row. - #[test] - fn rollback_gives_back_a_consumed_ack() { - let mut acks = DeferredAcks::default(); - acks.defer(&ack("OUT-1"), None, 0); - - acks.begin_batch(); - let pre_batch = acks.clone(); - assert_eq!(acks.take_matching("OUT-1", CHAT, 0).unwrap().id, "OUT-1"); - assert!(acks.take_matching("OUT-1", CHAT, 0).is_none()); - - acks = acks.rolled_back(pre_batch); - assert_eq!(acks.take_matching("OUT-1", CHAT, 0).unwrap().id, "OUT-1"); - } - - /// ...but it must NOT undo what it added. A `ServerAck` event is off the - /// writer channel by then and never redelivered, so dropping the deferral - /// is the silent permanent loss this whole queue exists to prevent. - #[test] - fn rollback_keeps_an_ack_the_batch_deferred() { - let mut acks = DeferredAcks::default(); - - acks.begin_batch(); - let pre_batch = acks.clone(); - acks.defer(&ack("OUT-NEW"), None, 0); - - acks = acks.rolled_back(pre_batch); - assert_eq!( - acks.take_matching("OUT-NEW", CHAT, 0).unwrap().id, - "OUT-NEW" - ); - } - - /// An ack deferred AND consumed inside the same failed batch loses both - /// mutations, so it is owed a row again. - #[test] - fn rollback_keeps_an_ack_the_batch_deferred_then_consumed() { - let mut acks = DeferredAcks::default(); - - acks.begin_batch(); - let pre_batch = acks.clone(); - acks.defer(&ack("OUT-BOTH"), None, 0); - assert_eq!( - acks.take_matching("OUT-BOTH", CHAT, 0).unwrap().id, - "OUT-BOTH" - ); - - acks = acks.rolled_back(pre_batch); - assert_eq!( - acks.take_matching("OUT-BOTH", CHAT, 0).unwrap().id, - "OUT-BOTH" - ); - } - - /// A transaction that panics poisons the queue's lock while holding acks - /// that have no other record. Reading through the poison is the whole - /// point: refusing would turn the panic into the silent loss. - #[test] - fn a_poisoned_queue_still_yields_its_acks() { - let acks = Arc::new(std::sync::Mutex::new(DeferredAcks::default())); - lock_deferred_acks(&acks).defer(&ack("OUT-PANIC"), None, 0); - - let poisoner = Arc::clone(&acks); - let panicked = std::thread::spawn(move || { - let _guard = lock_deferred_acks(&poisoner); - panic!("writer transaction blew up mid-batch"); - }) - .join(); - assert!(panicked.is_err(), "the thread must actually panic"); - assert!(acks.is_poisoned()); - - assert_eq!( - lock_deferred_acks(&acks) - .take_matching("OUT-PANIC", CHAT, 0) - .unwrap() - .id, - "OUT-PANIC" - ); - } - - /// A committed batch settles its additions; the next rollback must not - /// resurrect them. - #[test] - fn a_new_batch_forgets_the_previous_batch_additions() { - let mut acks = DeferredAcks::default(); - acks.begin_batch(); - acks.defer(&ack("OUT-OLD"), None, 0); - assert_eq!( - acks.take_matching("OUT-OLD", CHAT, 0).unwrap().id, - "OUT-OLD" - ); - - // Next batch commits nothing of its own and rolls back. - acks.begin_batch(); - let pre_batch = acks.clone(); - acks = acks.rolled_back(pre_batch); - - assert!( - acks.take_matching("OUT-OLD", CHAT, 0).is_none(), - "the previous batch committed that consumption" - ); - } -} diff --git a/storages/chat-store/src/types.rs b/storages/chat-store/src/types.rs deleted file mode 100644 index 9b4d0a9d2..000000000 --- a/storages/chat-store/src/types.rs +++ /dev/null @@ -1,335 +0,0 @@ -use chrono::{DateTime, Utc}; -use wacore_binary::Jid; -use waproto::whatsapp as wa; - -/// Content class of a stored message: one label per renderable bubble type, -/// shared by every frontend (desktop, TUI, mobile) so none of them hard-codes -/// label strings. Stored as text in the database; [`Other`](Self::Other) -/// round-trips labels written by a newer crate version. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum MessageKind { - Text, - Image, - Video, - /// Round video note ("ptv"). - VideoNote, - Audio, - /// Push-to-talk voice note ("ptt"). - VoiceNote, - Sticker, - Document, - Contact, - Location, - Poll, - Event, - GroupInvite, - /// Hydrated business template (WABA notification). - Template, - /// Reply to a template button. - TemplateReply, - Buttons, - ButtonsResponse, - List, - ListResponse, - Interactive, - InteractiveResponse, - /// Placeholder for a message that could not be decrypted **yet** — a - /// retry or a PDO placeholder-resend may still fill it in. - Undecryptable, - /// A view-once photo, video or voice note the server fanned out as - /// ``. The phone never shares that content with a companion, - /// so unlike [`Undecryptable`](Self::Undecryptable) this will not resolve — - /// it is the one-time chip WA Web renders ("open on your phone"), not a - /// "waiting for this message" placeholder. - ViewOnce, - /// A hosted-content fanout. Permanently unavailable to a companion, like - /// [`ViewOnce`](Self::ViewOnce). - Hosted, - /// A bot-message fanout. Permanently unavailable to a companion, like - /// [`ViewOnce`](Self::ViewOnce). - Bot, - /// Real content this crate version doesn't classify. - Unknown, - /// A database label written by a newer crate version. - Other(String), -} - -impl MessageKind { - /// The database label. Stable: these are on-disk values. - pub fn as_str(&self) -> &str { - match self { - Self::Text => "text", - Self::Image => "image", - Self::Video => "video", - Self::VideoNote => "ptv", - Self::Audio => "audio", - Self::VoiceNote => "ptt", - Self::Sticker => "sticker", - Self::Document => "document", - Self::Contact => "contact", - Self::Location => "location", - Self::Poll => "poll", - Self::Event => "event", - Self::GroupInvite => "group_invite", - Self::Template => "template", - Self::TemplateReply => "template_reply", - Self::Buttons => "buttons", - Self::ButtonsResponse => "buttons_response", - Self::List => "list", - Self::ListResponse => "list_response", - Self::Interactive => "interactive", - Self::InteractiveResponse => "interactive_response", - Self::Undecryptable => "undecryptable", - Self::ViewOnce => "view_once", - Self::Hosted => "hosted", - Self::Bot => "bot", - Self::Unknown => "unknown", - Self::Other(label) => label, - } - } - - pub(crate) fn from_db(label: String) -> Self { - match label.as_str() { - "text" => Self::Text, - "image" => Self::Image, - "video" => Self::Video, - "ptv" => Self::VideoNote, - "audio" => Self::Audio, - "ptt" => Self::VoiceNote, - "sticker" => Self::Sticker, - "document" => Self::Document, - "contact" => Self::Contact, - "location" => Self::Location, - "poll" => Self::Poll, - "event" => Self::Event, - "group_invite" => Self::GroupInvite, - "template" => Self::Template, - "template_reply" => Self::TemplateReply, - "buttons" => Self::Buttons, - "buttons_response" => Self::ButtonsResponse, - "list" => Self::List, - "list_response" => Self::ListResponse, - "interactive" => Self::Interactive, - "interactive_response" => Self::InteractiveResponse, - "undecryptable" => Self::Undecryptable, - "view_once" => Self::ViewOnce, - "hosted" => Self::Hosted, - "bot" => Self::Bot, - "unknown" => Self::Unknown, - _ => Self::Other(label), - } - } -} - -/// Delivery state of a stored message, on the same scale WhatsApp itself uses -/// (`WebMessageInfo.Status`), so history-sync statuses map through unchanged. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -#[repr(i32)] -pub enum MessageStatus { - Error = 0, - Pending = 1, - ServerAck = 2, - Delivered = 3, - Read = 4, - Played = 5, -} - -impl MessageStatus { - pub fn from_raw(raw: i32) -> Self { - match raw { - 0 => Self::Error, - 2 => Self::ServerAck, - 3 => Self::Delivered, - 4 => Self::Read, - 5 => Self::Played, - _ => Self::Pending, - } - } -} - -/// One row of the chat list, ordered for display (pinned first, then most -/// recent activity). -#[derive(Debug, Clone)] -pub struct ChatEntry { - pub jid: Jid, - pub name: Option, - pub last_message_at: Option>, - pub last_message_preview: Option, - /// Content class of the latest message, so a media preview can render as - /// "\[photo\]"/"\[voice note\]" in whatever way (and language) the frontend - /// chooses — the store never bakes in presentation strings. - pub last_message_kind: Option, - /// `-1` means "manually marked unread" (WA Web convention). - pub unread_count: i32, - pub pinned_at: Option>, - /// `Some(DateTime::MAX_UTC)` = muted forever (no expiry). - pub muted_until: Option>, - pub archived: bool, - pub ephemeral_expiration: Option, -} - -/// A stored message. `message` is the decoded proto when the row has one and -/// it decodes cleanly; the denormalized columns (`kind`, `text`) always work -/// even when it doesn't. -#[derive(Debug, Clone)] -pub struct StoredMessage { - pub chat_jid: Jid, - pub id: String, - pub sender_jid: Jid, - pub from_me: bool, - pub timestamp: DateTime, - pub kind: MessageKind, - pub text: Option, - pub message: Option>, - pub status: MessageStatus, - pub starred: bool, - pub edited_at: Option>, - pub revoked: bool, - /// Arrival order within this store, ascending. Opaque: compare it, don't - /// interpret it. It exists because the server's `t` is whole seconds, so - /// two messages exchanged in the same second carry the same `timestamp` - /// and something has to break the tie — this is the order the socket - /// delivered them in, which is the order both ends display. - /// - /// Comparable, not durable: a `VACUUM` preserves the relative order these - /// values encode but may renumber the values themselves, and SQLite hands - /// out the implicit rowid as `max(rowid) + 1`, so deleting the newest - /// message gives its number to the next arrival and clearing a chat - /// entirely restarts at 1. A `seq` (or a [`MessageCursor`]/[`ArrivalCursor`] - /// built from one) is good for a live paging session and must not be - /// persisted across restarts or compared against a remembered value — a new - /// message can legitimately land below one. - /// - /// It is *store* arrival, not wire arrival. Inbound rows are inserted as - /// the socket delivers them, but an outgoing row is inserted when the host - /// calls `record_outgoing`, which happens after its send resolves — so a - /// peer reply that is decrypted and materialized in that gap, and that - /// lands on the same whole second, takes the lower `seq`. That needs a full - /// round trip to complete inside one second while a local enqueue is still - /// pending, and it is bounded to same-second pairs; the ordering it - /// replaced was wrong for roughly three of every four such pairs, in a - /// fixed direction. - pub seq: i64, -} - -/// Keyset-pagination cursor: pass the values of the oldest message you have to -/// fetch the page before it. Never an OFFSET — stable under concurrent inserts. -#[derive(Debug, Clone)] -pub struct MessageCursor { - pub timestamp_ms: i64, - /// [`StoredMessage::seq`] of the same message. Must match the sort's - /// tiebreak exactly, or a page boundary that lands inside a same-second - /// run would skip or repeat rows. - pub seq: i64, -} - -impl From<&StoredMessage> for MessageCursor { - fn from(m: &StoredMessage) -> Self { - Self { - timestamp_ms: m.timestamp.timestamp_millis(), - seq: m.seq, - } - } -} - -/// Keyset-pagination cursor for the session-wide arrival feed: pass the -/// [`seq`](StoredMessage::seq) of the last message on the page you have to -/// fetch the page after it, which is the next batch of older arrivals. -/// -/// Separate from [`MessageCursor`] because the two order by different keys — a -/// per-chat page sorts by `(timestamp_ms, seq)` and the arrival feed sorts by -/// `seq` alone, so a cursor from one cannot page the other. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ArrivalCursor { - /// [`StoredMessage::seq`] of the last message on the previous page. - pub seq: i64, -} - -impl From<&StoredMessage> for ArrivalCursor { - fn from(m: &StoredMessage) -> Self { - Self { seq: m.seq } - } -} - -/// Keyset-pagination cursor for the chat list: pass the values of the last -/// chat you have to fetch the page after it. -/// -/// The list is two ordered runs — pinned chats by pin time, then the rest by -/// activity — so the cursor records which run it sits in (`pinned_at`) as well -/// as where. -#[derive(Debug, Clone)] -pub struct ChatCursor { - /// `Some` for a cursor inside the pinned run, `None` for the activity run. - pub pinned_at_ms: Option, - pub last_message_ts: i64, - pub jid: String, -} - -impl From<&ChatEntry> for ChatCursor { - fn from(c: &ChatEntry) -> Self { - Self { - pinned_at_ms: c.pinned_at.map(|t| t.timestamp_millis()), - last_message_ts: c.last_message_at.map_or(0, |t| t.timestamp_millis()), - jid: c.jid.to_string(), - } - } -} - -#[derive(Debug, Clone)] -pub struct ReactionEntry { - pub sender_jid: Jid, - pub emoji: String, - pub timestamp: DateTime, -} - -/// Per-user delivery/read state of one message (group "read by" lists). -#[derive(Debug, Clone)] -pub struct ReceiptEntry { - pub user_jid: Jid, - pub status: MessageStatus, - pub timestamp: DateTime, -} - -#[derive(Debug, Clone)] -pub struct ContactEntry { - pub jid: Jid, - pub push_name: Option, - pub full_name: Option, - pub first_name: Option, - pub business_name: Option, -} - -impl ContactEntry { - /// Best display name available, WA Web precedence: address book (full, - /// then first name), then push name, then business name. - pub fn display_name(&self) -> Option<&str> { - self.full_name - .as_deref() - .or(self.first_name.as_deref()) - .or(self.push_name.as_deref()) - .or(self.business_name.as_deref()) - } -} - -#[derive(Debug, Clone)] -pub struct MediaRef { - pub file_sha256: Vec, - pub file_path: String, - pub mime_type: Option, - pub size_bytes: Option, - pub downloaded_at: DateTime, -} - -/// Invalidation signal emitted after each committed write batch. Consumers -/// re-run the queries backing their visible state; the store never pushes row -/// data (query + invalidation, not cache duplication). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum StoreChange { - /// Chat-list-level change: ordering, previews, unread counts, membership. - Chats, - /// The message set of one chat changed (insert/edit/revoke/reaction/status). - Messages { chat: Jid }, - /// Contact naming changed. - Contacts, -} diff --git a/storages/chat-store/tests/chat_store_test.rs b/storages/chat-store/tests/chat_store_test.rs deleted file mode 100644 index c0a659cdd..000000000 --- a/storages/chat-store/tests/chat_store_test.rs +++ /dev/null @@ -1,6338 +0,0 @@ -//! Integration tests: real SqliteStore (in-memory), real writer task, events -//! fed through the public handler exactly as the client would. - -// Tests/benches exercise the raw buffa API. -#![allow(clippy::disallowed_methods)] - -use std::sync::Arc; -use std::time::Duration; - -use buffa::MessageField; -use chrono::{Datelike, TimeZone, Utc}; -use diesel::RunQueryDsl; -use wacore::proto_helpers::MessageBuilderExt; -use wacore::types::events::{ - BatchOrigin, Event, InboundMessage, LazyHistorySync, MessageBatch, Receipt, ServerAck, -}; -use wacore::types::message::{MessageInfo, MessageSource}; -use wacore::types::presence::ReceiptType; -use wacore_binary::Jid; -use waproto::whatsapp as wa; -use whatsapp_rust_chat_store::{ChatStore, MessageKind, MessageStatus, StoreChange}; -use whatsapp_rust_sqlite_storage::SqliteStore; - -const PEER: &str = "559900000001@s.whatsapp.net"; -const GROUP: &str = "120363000000000001@g.us"; - -async fn test_store() -> (SqliteStore, Arc) { - use portable_atomic::AtomicU64; - use std::sync::atomic::Ordering; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let id = COUNTER.fetch_add(1, Ordering::Relaxed); - let db_name = format!( - "file:memdb_chat_store_{}_{}?mode=memory&cache=shared", - std::process::id(), - id - ); - let store = SqliteStore::new(&db_name).await.expect("create store"); - let chat_store = ChatStore::new(&store).await.expect("create chat store"); - (store, chat_store) -} - -fn jid(s: &str) -> Jid { - s.parse().expect("valid test JID") -} - -fn incoming_info(chat: &str, sender: &str, id: &str, ts_secs: i64) -> MessageInfo { - MessageInfo { - source: MessageSource { - chat: jid(chat), - sender: jid(sender), - is_from_me: false, - is_group: chat.ends_with("@g.us"), - ..Default::default() - }, - id: id.to_string(), - timestamp: Utc.timestamp_opt(ts_secs, 0).unwrap(), - ..Default::default() - } -} - -fn message_event(msg: wa::Message, info: MessageInfo) -> Event { - Event::Messages( - MessageBatch::builder() - .messages(Arc::from([InboundMessage::builder() - .message(Arc::new(msg)) - .info(Arc::new(info)) - .build()])) - .origin(BatchOrigin::Live) - .build(), - ) -} - -async fn feed(chat_store: &ChatStore, events: impl IntoIterator) { - let handler = chat_store.handler(); - for event in events { - handler.handle_event(Arc::new(event)); - } - chat_store.flush().await.expect("flush"); -} - -fn history_sync_event(history: wa::HistorySync) -> Event { - use buffa::Message as _; - use flate2::{Compression, write::ZlibEncoder}; - use std::io::Write; - - let raw = history.encode_to_vec(); - let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); - enc.write_all(&raw).unwrap(); - Event::HistorySync(Box::new(LazyHistorySync::new( - enc.finish().unwrap().into(), - raw.len(), - wa::history_sync::HistorySyncType::RECENT as i32, - None, - None, - ))) -} - -#[tokio::test] -async fn live_text_message_materializes_chat_and_message() { - let (_store, chat_store) = test_store().await; - - let mut info = incoming_info(PEER, PEER, "MSG-1", 1_700_000_000); - info.push_name = "Alice Example".into(); - feed(&chat_store, [message_event(wa::Message::text("olá"), info)]).await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, jid(PEER)); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("olá")); - assert_eq!(chats[0].unread_count, 1); - - let messages = chat_store.messages(&jid(PEER), None, 10).await.unwrap(); - assert_eq!(messages.len(), 1); - let msg = &messages[0]; - assert_eq!(msg.id, "MSG-1"); - assert_eq!(msg.kind, MessageKind::Text); - assert_eq!(msg.text.as_deref(), Some("olá")); - assert!(!msg.from_me); - // The stored proto round-trips. - let proto = msg.message.as_ref().expect("decoded proto"); - assert_eq!(proto.conversation.as_deref(), Some("olá")); - - // Live push name landed in contacts. - let contact = chat_store.contact(&jid(PEER)).await.unwrap().unwrap(); - assert_eq!(contact.push_name.as_deref(), Some("Alice Example")); - assert_eq!(contact.display_name(), Some("Alice Example")); -} - -#[tokio::test] -async fn business_verified_name_is_learned_from_live_messages() { - let (_store, chat_store) = test_store().await; - - let mut info = incoming_info(PEER, PEER, "MSG-BIZ-1", 1_700_000_000); - info.verified_name = Some(Box::new(wacore::stanza::business::VerifiedName { - name: Some("Fictitious Biz Ltd".into()), - serial: Some("12345".into()), - issuer: Some("smb:wa".into()), - certificate: None, - })); - feed( - &chat_store, - [message_event(wa::Message::text("promo"), info)], - ) - .await; - - let contact = chat_store.contact(&jid(PEER)).await.unwrap().unwrap(); - assert_eq!(contact.business_name.as_deref(), Some("Fictitious Biz Ltd")); - // No address-book or push name: the verified name is the display name. - assert_eq!(contact.display_name(), Some("Fictitious Biz Ltd")); -} - -#[tokio::test] -async fn later_message_without_verified_name_keeps_business_name() { - let (_store, chat_store) = test_store().await; - - let mut info = incoming_info(PEER, PEER, "MSG-BIZ-2", 1_700_000_000); - info.verified_name = Some(Box::new(wacore::stanza::business::VerifiedName { - name: Some("Fictitious Biz Ltd".into()), - serial: None, - issuer: None, - certificate: None, - })); - let plain = incoming_info(PEER, PEER, "MSG-BIZ-3", 1_700_000_100); - feed( - &chat_store, - [ - message_event(wa::Message::text("promo"), info), - message_event(wa::Message::text("follow-up"), plain), - ], - ) - .await; - - let contact = chat_store.contact(&jid(PEER)).await.unwrap().unwrap(); - assert_eq!(contact.business_name.as_deref(), Some("Fictitious Biz Ltd")); -} - -#[tokio::test] -async fn nameless_verified_cert_creates_no_contact_row() { - let (_store, chat_store) = test_store().await; - - let mut info = incoming_info(PEER, PEER, "MSG-BIZ-4", 1_700_000_000); - info.verified_name = Some(Box::new(wacore::stanza::business::VerifiedName { - name: None, - serial: None, - issuer: None, - certificate: Some(vec![0xff, 0x13]), - })); - feed(&chat_store, [message_event(wa::Message::text("hi"), info)]).await; - - assert!(chat_store.contact(&jid(PEER)).await.unwrap().is_none()); -} - -#[tokio::test] -async fn outgoing_status_advances_monotonically() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let local_timestamp = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - - chat_store - .record_outgoing(&chat, "OUT-1", &wa::Message::text("oi"), local_timestamp) - .unwrap(); - chat_store.flush().await.unwrap(); - let msg = chat_store.message(&chat, "OUT-1").await.unwrap().unwrap(); - assert!(msg.from_me); - assert_eq!(msg.status, MessageStatus::Pending); - - // Server ack lifts to ServerAck. - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-1".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .build(), - )], - ) - .await; - let msg = chat_store.message(&chat, "OUT-1").await.unwrap().unwrap(); - assert_eq!(msg.status, MessageStatus::ServerAck); - assert_eq!(msg.timestamp, local_timestamp); - - // Read receipt from the peer. - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: chat.clone(), - sender: chat.clone(), - ..Default::default() - }) - .message_ids(vec!["OUT-1".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_200, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - )], - ) - .await; - let msg = chat_store.message(&chat, "OUT-1").await.unwrap().unwrap(); - assert_eq!(msg.status, MessageStatus::Read); - - // A late Delivered must NOT downgrade Read. - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: chat.clone(), - sender: chat.clone(), - ..Default::default() - }) - .message_ids(vec!["OUT-1".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_300, 0).unwrap()) - .r#type(ReceiptType::Delivered) - .offline(false) - .build(), - )], - ) - .await; - let msg = chat_store.message(&chat, "OUT-1").await.unwrap().unwrap(); - assert_eq!(msg.status, MessageStatus::Read); -} - -#[tokio::test] -async fn server_ack_reconciles_outgoing_timestamp_and_thread_order() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let server_timestamp = Utc.timestamp_opt(1_700_000_000, 0).unwrap(); - let reply_timestamp = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - let local_timestamp = Utc.timestamp_opt(1_700_000_200, 0).unwrap(); - - chat_store - .record_outgoing( - &chat, - "OUT-CLOCK", - &wa::Message::text("question"), - local_timestamp, - ) - .unwrap(); - feed( - &chat_store, - [message_event( - wa::Message::text("reply"), - incoming_info(PEER, PEER, "REPLY-CLOCK", reply_timestamp.timestamp()), - )], - ) - .await; - - let before = chat_store.messages(&chat, None, 10).await.unwrap(); - assert_eq!(before[0].id, "OUT-CLOCK"); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_at, Some(local_timestamp)); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("question")); - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-CLOCK".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .timestamp(server_timestamp) - .build(), - )], - ) - .await; - - let after = chat_store.messages(&chat, None, 10).await.unwrap(); - assert_eq!( - after - .iter() - .map(|message| message.id.as_str()) - .collect::>(), - ["REPLY-CLOCK", "OUT-CLOCK"] - ); - assert_eq!(after[1].timestamp, server_timestamp); - assert_eq!(after[1].status, MessageStatus::ServerAck); - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_at, Some(reply_timestamp)); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("reply")); -} - -#[tokio::test] -async fn server_ack_can_move_outgoing_message_to_thread_head() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let local_timestamp = Utc.timestamp_opt(1_700_000_000, 0).unwrap(); - let reply_timestamp = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - let server_timestamp = Utc.timestamp_opt(1_700_000_200, 0).unwrap(); - - chat_store - .record_outgoing( - &chat, - "OUT-CLOCK-FORWARD", - &wa::Message::text("question"), - local_timestamp, - ) - .unwrap(); - feed( - &chat_store, - [message_event( - wa::Message::text("reply"), - incoming_info( - PEER, - PEER, - "REPLY-CLOCK-FORWARD", - reply_timestamp.timestamp(), - ), - )], - ) - .await; - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-CLOCK-FORWARD".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .timestamp(server_timestamp) - .build(), - )], - ) - .await; - - let messages = chat_store.messages(&chat, None, 10).await.unwrap(); - assert_eq!(messages[0].id, "OUT-CLOCK-FORWARD"); - assert_eq!(messages[0].timestamp, server_timestamp); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_at, Some(server_timestamp)); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("question")); -} - -#[tokio::test] -async fn server_ack_reconciles_timestamp_after_receipt_advanced_status() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let server_timestamp = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - - chat_store - .record_outgoing( - &chat, - "OUT-RECEIPT-FIRST", - &wa::Message::text("hello"), - Utc.timestamp_opt(1_700_000_200, 0).unwrap(), - ) - .unwrap(); - feed( - &chat_store, - [ - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: chat.clone(), - sender: chat.clone(), - ..Default::default() - }) - .message_ids(vec!["OUT-RECEIPT-FIRST".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_300, 0).unwrap()) - .r#type(ReceiptType::Delivered) - .offline(false) - .build(), - ), - Event::ServerAck( - ServerAck::builder() - .id("OUT-RECEIPT-FIRST".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .timestamp(server_timestamp) - .build(), - ), - ], - ) - .await; - - let msg = chat_store - .message(&chat, "OUT-RECEIPT-FIRST") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Delivered); - assert_eq!(msg.timestamp, server_timestamp); -} - -#[tokio::test] -async fn server_nack_does_not_reconcile_timestamp() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let local_timestamp = Utc.timestamp_opt(1_700_000_000, 0).unwrap(); - - chat_store - .record_outgoing( - &chat, - "OUT-NACK-CLOCK", - &wa::Message::text("hello"), - local_timestamp, - ) - .unwrap(); - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-NACK-CLOCK".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .timestamp(Utc.timestamp_opt(1_700_000_500, 0).unwrap()) - .error("479".to_string()) - .build(), - )], - ) - .await; - - let msg = chat_store - .message(&chat, "OUT-NACK-CLOCK") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Error); - assert_eq!(msg.timestamp, local_timestamp); -} - -#[tokio::test] -async fn server_ack_disambiguates_reused_message_id_by_chat() { - let (_store, chat_store) = test_store().await; - let peer = jid(PEER); - let group = jid(GROUP); - let peer_timestamp = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - let group_timestamp = Utc.timestamp_opt(1_700_000_200, 0).unwrap(); - let server_timestamp = Utc.timestamp_opt(1_700_000_300, 0).unwrap(); - let shared_id = "OUT-SHARED-ID"; - - chat_store - .record_outgoing(&peer, shared_id, &wa::Message::text("peer"), peer_timestamp) - .unwrap(); - chat_store - .record_outgoing( - &group, - shared_id, - &wa::Message::text("group"), - group_timestamp, - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - // Without a usable chat identity, a duplicate id is ambiguous and must - // update neither row. - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id(shared_id.to_string()) - .class("message".to_string()) - .timestamp(server_timestamp) - .build(), - )], - ) - .await; - for (chat, timestamp) in [(&peer, peer_timestamp), (&group, group_timestamp)] { - let msg = chat_store.message(chat, shared_id).await.unwrap().unwrap(); - assert_eq!(msg.status, MessageStatus::Pending); - assert_eq!(msg.timestamp, timestamp); - } - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id(shared_id.to_string()) - .class("message".to_string()) - .from(group.clone()) - .timestamp(server_timestamp) - .build(), - )], - ) - .await; - - let peer_msg = chat_store.message(&peer, shared_id).await.unwrap().unwrap(); - assert_eq!(peer_msg.status, MessageStatus::Pending); - assert_eq!(peer_msg.timestamp, peer_timestamp); - let group_msg = chat_store - .message(&group, shared_id) - .await - .unwrap() - .unwrap(); - assert_eq!(group_msg.status, MessageStatus::ServerAck); - assert_eq!(group_msg.timestamp, server_timestamp); -} - -#[tokio::test] -async fn server_ack_refreshes_preview_behind_retained_activity_timestamp() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let server_timestamp = Utc.timestamp_opt(1_700_000_050, 0).unwrap(); - let survivor_timestamp = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - let local_timestamp = Utc.timestamp_opt(1_700_000_200, 0).unwrap(); - let deleted_timestamp = Utc.timestamp_opt(1_700_000_300, 0).unwrap(); - - chat_store - .record_outgoing( - &chat, - "OUT-RETAINED-HEAD", - &wa::Message::text("question"), - local_timestamp, - ) - .unwrap(); - feed( - &chat_store, - [ - message_event( - wa::Message::text("survivor"), - incoming_info(PEER, PEER, "MSG-SURVIVOR", survivor_timestamp.timestamp()), - ), - message_event( - wa::Message::text("delete me"), - incoming_info( - PEER, - PEER, - "MSG-DELETED-HEAD", - deleted_timestamp.timestamp(), - ), - ), - ], - ) - .await; - feed( - &chat_store, - [Event::DeleteMessageForMeUpdate( - wacore::types::events::DeleteMessageForMeUpdate::builder() - .chat_jid(chat.clone()) - .message_id("MSG-DELETED-HEAD".to_string()) - .from_me(false) - .timestamp(Utc.timestamp_opt(1_700_000_400, 0).unwrap()) - .action(Box::new( - wa::sync_action_value::DeleteMessageForMeAction::default(), - )) - .from_full_sync(false) - .build(), - )], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_at, Some(deleted_timestamp)); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("question")); - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-RETAINED-HEAD".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .timestamp(server_timestamp) - .build(), - )], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_at, Some(deleted_timestamp)); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("survivor")); -} - -#[tokio::test] -async fn edit_updates_and_revoke_tombstones() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [message_event( - wa::Message::text("typo"), - incoming_info(PEER, PEER, "MSG-E", 1_700_000_000), - )], - ) - .await; - - // Edit arrives as protocolMessage MESSAGE_EDIT targeting the original id. - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-E".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("fixed"))), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - edit, - incoming_info(PEER, PEER, "MSG-E2", 1_700_000_050), - )], - ) - .await; - let msg = chat_store.message(&chat, "MSG-E").await.unwrap().unwrap(); - assert_eq!(msg.text.as_deref(), Some("fixed")); - assert!(msg.edited_at.is_some()); - assert!(!msg.revoked); - // The edit protocol message itself must not create a bubble row. - assert!(chat_store.message(&chat, "MSG-E2").await.unwrap().is_none()); - - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-E".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(PEER, PEER, "MSG-E3", 1_700_000_060), - )], - ) - .await; - let msg = chat_store.message(&chat, "MSG-E").await.unwrap().unwrap(); - assert!(msg.revoked); - assert!(msg.text.is_none()); - assert!(msg.message.is_none()); -} - -#[tokio::test] -async fn reactions_add_replace_and_remove() { - let (_store, chat_store) = test_store().await; - let chat = jid(GROUP); - let alice = "559900000002@s.whatsapp.net"; - - feed( - &chat_store, - [message_event( - wa::Message::text("target"), - incoming_info(GROUP, PEER, "MSG-R", 1_700_000_000), - )], - ) - .await; - - let react = |emoji: &str, id: &str, ts: i64| { - message_event( - wa::Message { - reaction_message: MessageField::some(wa::message::ReactionMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-R".into()), - ..Default::default() - }), - text: Some(emoji.into()), - ..Default::default() - }), - ..Default::default() - }, - incoming_info(GROUP, alice, id, ts), - ) - }; - - feed(&chat_store, [react("👍", "R1", 1_700_000_010)]).await; - let reactions = chat_store.reactions(&chat, "MSG-R").await.unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "👍"); - assert_eq!(reactions[0].sender_jid, jid(alice)); - - // Same sender replaces their reaction (PK upsert), doesn't add a second. - feed(&chat_store, [react("❤️", "R2", 1_700_000_020)]).await; - let reactions = chat_store.reactions(&chat, "MSG-R").await.unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "❤️"); - - // Empty text removes it. - feed(&chat_store, [react("", "R3", 1_700_000_030)]).await; - assert!( - chat_store - .reactions(&chat, "MSG-R") - .await - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn local_edit_updates_own_message_and_preview() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - chat_store - .record_outgoing( - &chat, - "OUT-EDIT", - &wa::Message::text("typo"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_edit( - &chat, - "OUT-EDIT", - &wa::Message::text("fixed"), - Utc.timestamp_opt(1_700_000_050, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let msg = chat_store - .message(&chat, "OUT-EDIT") - .await - .unwrap() - .unwrap(); - assert!(msg.from_me); - assert_eq!(msg.text.as_deref(), Some("fixed")); - assert_eq!( - msg.message - .as_deref() - .and_then(|message| message.conversation.as_deref()), - Some("fixed") - ); - assert_eq!( - msg.edited_at.map(|timestamp| timestamp.timestamp()), - Some(1_700_000_050) - ); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("fixed")); - - // The local API keeps the event path's monotonic edit semantics. - chat_store - .record_edit( - &chat, - "OUT-EDIT", - &wa::Message::text("stale"), - Utc.timestamp_opt(1_700_000_025, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - let msg = chat_store - .message(&chat, "OUT-EDIT") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.text.as_deref(), Some("fixed")); -} - -#[tokio::test] -async fn local_revoke_tombstones_own_message_and_absorbs_edits() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - chat_store - .record_outgoing( - &chat, - "OUT-REVOKE", - &wa::Message::text("delete me"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_revoke( - &chat, - "OUT-REVOKE", - Utc.timestamp_opt(1_700_000_050, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let msg = chat_store - .message(&chat, "OUT-REVOKE") - .await - .unwrap() - .unwrap(); - assert!(msg.revoked); - assert!(msg.text.is_none()); - assert!(msg.message.is_none()); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert!(chats[0].last_message_preview.is_none()); - - chat_store - .record_edit( - &chat, - "OUT-REVOKE", - &wa::Message::text("resurrected"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - let msg = chat_store - .message(&chat, "OUT-REVOKE") - .await - .unwrap() - .unwrap(); - assert!(msg.revoked); - assert!(msg.text.is_none()); -} - -#[tokio::test] -async fn local_reaction_adds_replaces_and_removes_own_reaction() { - let (_store, chat_store) = test_store().await; - let chat = jid(GROUP); - let target = wa::MessageKey { - remote_jid: Some(GROUP.into()), - from_me: Some(false), - id: Some("MSG-LOCAL-REACTION".into()), - participant: Some(PEER.into()), - }; - - feed( - &chat_store, - [message_event( - wa::Message::text("target"), - incoming_info(GROUP, PEER, "MSG-LOCAL-REACTION", 1_700_000_000), - )], - ) - .await; - - chat_store - .record_reaction( - &chat, - &target, - "👍", - Utc.timestamp_opt(1_700_000_020, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - let reactions = chat_store - .reactions(&chat, "MSG-LOCAL-REACTION") - .await - .unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "👍"); - assert_eq!(reactions[0].sender_jid, Jid::default()); - - // A stale local mirror cannot replace the latest reaction. - chat_store - .record_reaction( - &chat, - &target, - "❤️", - Utc.timestamp_opt(1_700_000_010, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - let reactions = chat_store - .reactions(&chat, "MSG-LOCAL-REACTION") - .await - .unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "👍"); - - chat_store - .record_reaction( - &chat, - &target, - "❤️", - Utc.timestamp_opt(1_700_000_030, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_reaction( - &chat, - &target, - "", - Utc.timestamp_opt(1_700_000_040, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - assert!( - chat_store - .reactions(&chat, "MSG-LOCAL-REACTION") - .await - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn local_reaction_requires_a_target_id() { - let (_store, chat_store) = test_store().await; - let err = chat_store - .record_reaction( - &jid(PEER), - &wa::MessageKey::default(), - "👍", - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .expect_err("missing target id must fail"); - assert!(err.to_string().contains("storage error")); -} - -#[tokio::test] -async fn local_reaction_checks_the_target_author_on_id_collision() { - let (store, chat_store) = test_store().await; - let chat = jid(GROUP); - let mallory = "559900000066@s.whatsapp.net"; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("surviving target"), - incoming_info(GROUP, PEER, "REACTION-COLLISION", 1_700_000_000), - ), - message_event( - wa::Message::text("colliding target"), - incoming_info(GROUP, mallory, "REACTION-COLLISION", 1_700_000_010), - ), - ], - ) - .await; - - let target_for = |participant: &str| wa::MessageKey { - remote_jid: Some(GROUP.into()), - from_me: Some(false), - id: Some("REACTION-COLLISION".into()), - participant: Some(participant.into()), - }; - chat_store - .record_reaction( - &chat, - &target_for(mallory), - "👎", - Utc.timestamp_opt(1_700_000_020, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - assert!( - chat_store - .reactions(&chat, "REACTION-COLLISION") - .await - .unwrap() - .is_empty(), - "a key for the colliding author must not attach to the surviving target" - ); - - // Device suffixes and the peer's mapped PN/LID alias do not change the - // participant's author identity. - add_lid_mapping(&store).await; - chat_store - .record_reaction( - &chat, - &target_for("111000011112222:48@lid"), - "👍", - Utc.timestamp_opt(1_700_000_030, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - let reactions = chat_store - .reactions(&chat, "REACTION-COLLISION") - .await - .unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "👍"); -} - -#[tokio::test] -async fn local_reaction_removal_blocks_stale_history_reaction() { - let (_store, chat_store) = test_store().await; - let chat = jid(GROUP); - let target = wa::MessageKey { - remote_jid: Some(GROUP.into()), - from_me: Some(false), - id: Some("MSG-REACTION-TOMBSTONE".into()), - participant: Some(PEER.into()), - }; - - feed( - &chat_store, - [message_event( - wa::Message::text("target"), - incoming_info(GROUP, PEER, "MSG-REACTION-TOMBSTONE", 1_700_000_000), - )], - ) - .await; - chat_store - .record_reaction( - &chat, - &target, - "👍", - Utc.timestamp_opt(1_700_000_010, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_reaction( - &chat, - &target, - "", - Utc.timestamp_opt(1_700_000_020, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let history = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::RECENT, - conversations: vec![wa::Conversation { - id: GROUP.to_string(), - messages: vec![wa::HistorySyncMsg { - message: MessageField::some(wa::WebMessageInfo { - key: MessageField::some(target.clone()), - reactions: vec![wa::Reaction { - key: MessageField::some(wa::MessageKey { - from_me: Some(true), - ..target.clone() - }), - text: Some("👍".into()), - sender_timestamp_ms: Some(1_700_000_010_000), - ..Default::default() - }], - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }], - ..Default::default() - }; - feed(&chat_store, [history_sync_event(history)]).await; - assert!( - chat_store - .reactions(&chat, "MSG-REACTION-TOMBSTONE") - .await - .unwrap() - .is_empty(), - "stale history must not resurrect a removed reaction" - ); - - // A genuinely newer reaction still replaces the hidden tombstone. - chat_store - .record_reaction( - &chat, - &target, - "❤️", - Utc.timestamp_opt(1_700_000_030, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - let reactions = chat_store - .reactions(&chat, "MSG-REACTION-TOMBSTONE") - .await - .unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "❤️"); -} - -#[tokio::test] -async fn local_amendments_do_not_mutate_a_colliding_peer_message() { - let (_store, chat_store) = test_store().await; - let chat = jid(GROUP); - - feed( - &chat_store, - [message_event( - wa::Message::text("peer content"), - incoming_info(GROUP, PEER, "COLLIDING-ID", 1_700_000_000), - )], - ) - .await; - chat_store - .record_outgoing( - &chat, - "COLLIDING-ID", - &wa::Message::text("own colliding content"), - Utc.timestamp_opt(1_700_000_010, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_edit( - &chat, - "COLLIDING-ID", - &wa::Message::text("own edit"), - Utc.timestamp_opt(1_700_000_020, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_revoke( - &chat, - "COLLIDING-ID", - Utc.timestamp_opt(1_700_000_030, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let msg = chat_store - .message(&chat, "COLLIDING-ID") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.sender_jid, jid(PEER)); - assert!(!msg.from_me); - assert_eq!(msg.text.as_deref(), Some("peer content")); - assert!(!msg.revoked); -} - -#[tokio::test] -async fn keyset_pagination_covers_all_pages_in_order() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - let events: Vec = (0..5) - .map(|i| { - message_event( - wa::Message::text(format!("m{i}")), - incoming_info(PEER, PEER, &format!("MSG-{i}"), 1_700_000_000 + i), - ) - }) - .collect(); - feed(&chat_store, events).await; - - let mut seen = Vec::new(); - let mut cursor = None; - loop { - let page = chat_store.messages(&chat, cursor.take(), 2).await.unwrap(); - if page.is_empty() { - break; - } - assert!(page.len() <= 2); - cursor = page.last().map(Into::into); - seen.extend(page.into_iter().map(|m| m.text.unwrap())); - } - // Newest first, no duplicates, no gaps. - assert_eq!(seen, ["m4", "m3", "m2", "m1", "m0"]); -} - -#[tokio::test] -async fn history_sync_materializes_without_clobbering_live_rows() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - // A live copy arrives first (e.g. offline drain beat the history chunk). - feed( - &chat_store, - [message_event( - wa::Message::text("live copy"), - incoming_info(PEER, PEER, "MSG-H1", 1_700_000_000), - )], - ) - .await; - - let make_wmi = |id: &str, from_me: bool, ts: u64, text: &str| wa::WebMessageInfo { - key: MessageField::some(wa::MessageKey { - remote_jid: Some(PEER.into()), - from_me: Some(from_me), - id: Some(id.into()), - ..Default::default() - }), - message: MessageField::from_box(Box::new(wa::Message::text(text))), - message_timestamp: Some(ts), - status: Some(wa::web_message_info::Status::READ), - push_name: Some("Alice Example".into()), - ..Default::default() - }; - let history = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::RECENT, - conversations: vec![ - // Fresh chat (no live row): mute/pin land via the INSERT path. - // Wire values are unix seconds; the store must convert to ms. - wa::Conversation { - id: "559900000004@s.whatsapp.net".to_string(), - conversation_timestamp: Some(1_700_000_500), - mute_end_time: Some(1_800_000_000), - pinned: Some(1_700_000_800), - ..Default::default() - }, - wa::Conversation { - id: PEER.to_string(), - name: Some("Alice".into()), - conversation_timestamp: Some(1_700_000_900), - unread_count: Some(0), - messages: vec![ - wa::HistorySyncMsg { - message: MessageField::some(make_wmi( - "MSG-H1", - false, - 1_700_000_000, - "stale history copy", - )), - ..Default::default() - }, - wa::HistorySyncMsg { - message: MessageField::some(make_wmi( - "MSG-H2", - true, - 1_700_000_900, - "sent", - )), - ..Default::default() - }, - ], - ..Default::default() - }, - ], - pushnames: vec![wa::Pushname { - id: Some("559900000003@s.whatsapp.net".into()), - pushname: Some("Bob Example".into()), - }], - ..Default::default() - }; - - feed(&chat_store, [history_sync_event(history)]).await; - - // Chat identity from history; live message content preserved. - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 2); - let alice = chats - .iter() - .find(|c| c.jid == jid(PEER)) - .expect("alice chat"); - assert_eq!(alice.name.as_deref(), Some("Alice")); - // History backfills the denormalized preview (newest materialized row). - assert_eq!(alice.last_message_preview.as_deref(), Some("sent")); - assert_eq!(alice.last_message_kind, Some(MessageKind::Text)); - // Seconds-to-ms conversion: a future mute/pin must not decode as 1970. - let muted = chats - .iter() - .find(|c| c.jid == jid("559900000004@s.whatsapp.net")) - .expect("muted chat"); - assert!(muted.muted_until.unwrap().year() > 2020); - assert!(muted.pinned_at.unwrap().year() > 2020); - - let live = chat_store.message(&chat, "MSG-H1").await.unwrap().unwrap(); - assert_eq!(live.text.as_deref(), Some("live copy")); - - let hist = chat_store.message(&chat, "MSG-H2").await.unwrap().unwrap(); - assert!(hist.from_me); - assert_eq!(hist.text.as_deref(), Some("sent")); - assert_eq!(hist.status, MessageStatus::Read); - - // Pushnames from the remainder landed. - let bob = chat_store - .contact(&jid("559900000003@s.whatsapp.net")) - .await - .unwrap() - .unwrap(); - assert_eq!(bob.push_name.as_deref(), Some("Bob Example")); -} - -#[tokio::test] -async fn undecryptable_placeholder_is_replaced_by_recovery() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - let info = incoming_info(PEER, PEER, "MSG-U", 1_700_000_000); - feed( - &chat_store, - [Event::UndecryptableMessage( - wacore::types::events::UndecryptableMessage::builder() - .info(Arc::new(info.clone())) - .is_unavailable(false) - .unavailable_type(wacore::types::events::UnavailableType::Unknown) - .decrypt_fail_mode(wacore::types::events::DecryptFailMode::Show) - .build(), - )], - ) - .await; - let placeholder = chat_store.message(&chat, "MSG-U").await.unwrap().unwrap(); - assert_eq!(placeholder.kind, MessageKind::Undecryptable); - assert!(placeholder.message.is_none()); - - // PDO/retry later recovers the real content under the same id. - feed( - &chat_store, - [message_event(wa::Message::text("recovered"), info)], - ) - .await; - let recovered = chat_store.message(&chat, "MSG-U").await.unwrap().unwrap(); - assert_eq!(recovered.kind, MessageKind::Text); - assert_eq!(recovered.text.as_deref(), Some("recovered")); -} - -#[tokio::test] -async fn mark_chat_as_read_resets_unread_count() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("a"), - incoming_info(PEER, PEER, "MSG-A", 1_700_000_000), - ), - message_event( - wa::Message::text("b"), - incoming_info(PEER, PEER, "MSG-B", 1_700_000_001), - ), - ], - ) - .await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, 2); - assert_eq!(chat_store.unread_total().await.unwrap(), 2); - - feed( - &chat_store, - [Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_100, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(true), - ..Default::default() - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, 0); - assert_eq!(chat_store.unread_total().await.unwrap(), 0); -} - -#[tokio::test] -async fn invalidation_broadcast_fires_per_batch() { - let (_store, chat_store) = test_store().await; - let mut changes = chat_store.subscribe(); - - feed( - &chat_store, - [message_event( - wa::Message::text("ping"), - incoming_info(PEER, PEER, "MSG-N", 1_700_000_000), - )], - ) - .await; - - let mut got_chats = false; - let mut got_messages = false; - // Both signals were sent before flush() returned; drain with a timeout so - // a regression fails fast instead of hanging. - for _ in 0..3 { - match tokio::time::timeout(Duration::from_secs(5), changes.recv()).await { - Ok(Ok(StoreChange::Chats)) => got_chats = true, - Ok(Ok(StoreChange::Messages { chat })) => { - assert_eq!(chat, jid(PEER)); - got_messages = true; - } - Ok(Ok(StoreChange::Contacts)) => {} - Ok(Err(_)) | Err(_) => break, - } - if got_chats && got_messages { - break; - } - } - assert!(got_chats && got_messages); -} - -#[tokio::test] -async fn group_receipts_track_per_user_state() { - let (_store, chat_store) = test_store().await; - let group = jid(GROUP); - let alice = "559900000002@s.whatsapp.net"; - - chat_store - .record_outgoing( - &group, - "OUT-G", - &wa::Message::text("hey group"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - // Production receipts leave is_group defaulted (false); the - // store must derive groupness from the chat JID. - .source(MessageSource { - chat: group.clone(), - sender: jid(alice), - ..Default::default() - }) - .message_ids(vec!["OUT-G".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_010, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - )], - ) - .await; - - let receipts = chat_store.receipts(&group, "OUT-G").await.unwrap(); - assert_eq!(receipts.len(), 1); - assert_eq!(receipts[0].user_jid, jid(alice)); - assert_eq!(receipts[0].status, MessageStatus::Read); -} - -#[cfg(feature = "search")] -#[tokio::test] -async fn full_text_search_finds_and_survives_operator_input() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("reunião amanhã às dez"), - incoming_info(PEER, PEER, "MSG-S1", 1_700_000_000), - ), - message_event( - wa::Message::text("outra coisa qualquer"), - incoming_info(PEER, PEER, "MSG-S2", 1_700_000_001), - ), - ], - ) - .await; - - let hits = chat_store.search_messages("reunião", 10).await.unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "MSG-S1"); - - // Prefix match on partial words. - let hits = chat_store.search_messages("aman", 10).await.unwrap(); - assert_eq!(hits.len(), 1); - - // FTS5 operator characters must not produce a syntax error. - let hits = chat_store - .search_messages("reunião AND NOT (\"", 10) - .await - .unwrap(); - assert!(hits.len() <= 1); - - // Edited text re-indexes. - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-S2".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("agora relevante"))), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - edit, - incoming_info(PEER, PEER, "MSG-S3", 1_700_000_002), - )], - ) - .await; - let hits = chat_store.search_messages("relevante", 10).await.unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "MSG-S2"); - assert!( - chat_store - .search_messages("outra", 10) - .await - .unwrap() - .is_empty() - ); - - // NULL transitions must keep the index sound: revoke clears text - // (text -> NULL) and a recovered placeholder gains text (NULL -> text). - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-S1".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(PEER, PEER, "MSG-S4", 1_700_000_003), - )], - ) - .await; - assert!( - chat_store - .search_messages("reunião", 10) - .await - .unwrap() - .is_empty() - ); - - let info = incoming_info(PEER, PEER, "MSG-S5", 1_700_000_004); - feed( - &chat_store, - [Event::UndecryptableMessage( - wacore::types::events::UndecryptableMessage::builder() - .info(Arc::new(info.clone())) - .is_unavailable(false) - .unavailable_type(wacore::types::events::UnavailableType::Unknown) - .decrypt_fail_mode(wacore::types::events::DecryptFailMode::Show) - .build(), - )], - ) - .await; - feed( - &chat_store, - [message_event( - wa::Message::text("conteúdo recuperado"), - info, - )], - ) - .await; - let hits = chat_store.search_messages("recuperado", 10).await.unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "MSG-S5"); -} - -#[tokio::test] -async fn revoke_before_content_is_not_resurrected() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - // Offline drain can deliver the revoke before the content it targets. - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-RB".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(PEER, PEER, "MSG-RB2", 1_700_000_010), - )], - ) - .await; - let tombstone = chat_store.message(&chat, "MSG-RB").await.unwrap().unwrap(); - assert!(tombstone.revoked); - - // The content arriving later (redelivery path, overwrite=true) must not - // un-revoke the tombstone. - feed( - &chat_store, - [message_event( - wa::Message::text("too late"), - incoming_info(PEER, PEER, "MSG-RB", 1_700_000_000), - )], - ) - .await; - let still_revoked = chat_store.message(&chat, "MSG-RB").await.unwrap().unwrap(); - assert!(still_revoked.revoked); - assert!(still_revoked.text.is_none()); - // ...and the skipped redelivery must not surface its content in the - // chat-list preview either. - let chats = chat_store.chats(false, 10).await.unwrap(); - assert!( - chats - .iter() - .all(|c| c.last_message_preview.as_deref() != Some("too late")) - ); -} - -#[tokio::test] -async fn edit_of_revoked_message_is_a_no_op() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [message_event( - wa::Message::text("original"), - incoming_info(PEER, PEER, "MSG-ER", 1_700_000_000), - )], - ) - .await; - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-ER".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(PEER, PEER, "MSG-ER2", 1_700_000_010), - )], - ) - .await; - - // An edit targeting the tombstone must not resurrect content. - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-ER".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("resurrected"))), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - edit, - incoming_info(PEER, PEER, "MSG-ER3", 1_700_000_020), - )], - ) - .await; - let msg = chat_store.message(&chat, "MSG-ER").await.unwrap().unwrap(); - assert!(msg.revoked); - assert!(msg.text.is_none()); - assert!(msg.message.is_none()); -} - -#[tokio::test] -async fn same_millisecond_sibling_does_not_hijack_preview() { - let (_store, chat_store) = test_store().await; - - // Two messages in the same millisecond; (timestamp, msg_id) ordering makes - // MSG-Z2 the latest. - feed( - &chat_store, - [ - message_event( - wa::Message::text("first"), - incoming_info(PEER, PEER, "MSG-A1", 1_700_000_000), - ), - message_event( - wa::Message::text("second"), - incoming_info(PEER, PEER, "MSG-Z2", 1_700_000_000), - ), - ], - ) - .await; - - // Editing the OLDER same-millisecond sibling must not steal the preview. - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-A1".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("hijacked"))), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - edit, - incoming_info(PEER, PEER, "MSG-E1", 1_700_000_100), - )], - ) - .await; - let msg = chat_store - .message(&jid(PEER), "MSG-A1") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.text.as_deref(), Some("hijacked")); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("second")); -} - -#[tokio::test] -async fn pdo_recovery_does_not_double_count_unread() { - let (_store, chat_store) = test_store().await; - - let info = incoming_info(PEER, PEER, "MSG-DC", 1_700_000_000); - feed( - &chat_store, - [Event::UndecryptableMessage( - wacore::types::events::UndecryptableMessage::builder() - .info(Arc::new(info.clone())) - .is_unavailable(false) - .unavailable_type(wacore::types::events::UnavailableType::Unknown) - .decrypt_fail_mode(wacore::types::events::DecryptFailMode::Show) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); - - // PDO recovery replaces the placeholder under the same id: same message, - // must not count twice. - feed( - &chat_store, - [message_event(wa::Message::text("recovered"), info)], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn edit_of_latest_message_refreshes_preview_and_stale_edit_is_ignored() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [message_event( - wa::Message::text("original"), - incoming_info(PEER, PEER, "MSG-EP", 1_700_000_000), - )], - ) - .await; - - let edit_with = |text: &str, id: &str, ts: i64| { - message_event( - wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-EP".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text(text))), - ..Default::default() - }), - ..Default::default() - }, - incoming_info(PEER, PEER, id, ts), - ) - }; - - feed(&chat_store, [edit_with("edited", "E1", 1_700_000_100)]).await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("edited")); - - // A stale edit (older than the applied one) must not roll content back. - feed(&chat_store, [edit_with("stale", "E2", 1_700_000_050)]).await; - let msg = chat_store.message(&chat, "MSG-EP").await.unwrap().unwrap(); - assert_eq!(msg.text.as_deref(), Some("edited")); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("edited")); -} - -#[tokio::test] -async fn delete_for_me_cleans_satellites_and_recomputes_preview() { - let (_store, chat_store) = test_store().await; - let group = jid(GROUP); - let alice = "559900000002@s.whatsapp.net"; - - feed( - &chat_store, - [message_event( - wa::Message::text("keep me"), - incoming_info(GROUP, PEER, "MSG-K", 1_700_000_000), - )], - ) - .await; - chat_store - .record_outgoing( - &group, - "MSG-D", - &wa::Message::text("delete me"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: group.clone(), - sender: jid(alice), - is_group: true, - ..Default::default() - }) - .message_ids(vec!["MSG-D".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_110, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.receipts(&group, "MSG-D").await.unwrap().len(), 1); - - feed( - &chat_store, - [Event::DeleteMessageForMeUpdate( - wacore::types::events::DeleteMessageForMeUpdate::builder() - .chat_jid(group.clone()) - .message_id("MSG-D".to_string()) - .from_me(true) - .timestamp(Utc.timestamp_opt(1_700_000_200, 0).unwrap()) - .action(Box::new( - wa::sync_action_value::DeleteMessageForMeAction::default(), - )) - .from_full_sync(false) - .build(), - )], - ) - .await; - - assert!(chat_store.message(&group, "MSG-D").await.unwrap().is_none()); - assert!( - chat_store - .receipts(&group, "MSG-D") - .await - .unwrap() - .is_empty() - ); - // The chat-list preview falls back to the newest remaining message. - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("keep me")); -} - -#[tokio::test] -async fn stale_reaction_timestamp_does_not_replace_newer() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [message_event( - wa::Message::text("target"), - incoming_info(PEER, PEER, "MSG-RT", 1_700_000_000), - )], - ) - .await; - let react = |emoji: &str, id: &str, ts: i64| { - message_event( - wa::Message { - reaction_message: MessageField::some(wa::message::ReactionMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-RT".into()), - ..Default::default() - }), - text: Some(emoji.into()), - ..Default::default() - }), - ..Default::default() - }, - incoming_info(PEER, PEER, id, ts), - ) - }; - feed(&chat_store, [react("👍", "R1", 1_700_000_200)]).await; - // An older copy (e.g. replayed from a history chunk) must not win. - feed(&chat_store, [react("❤️", "R2", 1_700_000_100)]).await; - let reactions = chat_store.reactions(&chat, "MSG-RT").await.unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "👍"); - - // Neither must a stale REMOVE delete it... - feed(&chat_store, [react("", "R3", 1_700_000_150)]).await; - let reactions = chat_store.reactions(&chat, "MSG-RT").await.unwrap(); - assert_eq!(reactions.len(), 1); - - // ...while a newer remove still works. - feed(&chat_store, [react("", "R4", 1_700_000_300)]).await; - assert!( - chat_store - .reactions(&chat, "MSG-RT") - .await - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn flush_surfaces_a_failed_batch() { - let (store, chat_store) = test_store().await; - - // Sabotage the schema so the next batch rolls back. - store - .shared() - .run(|conn| { - diesel::sql_query("ALTER TABLE messages RENAME TO messages_gone") - .execute(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - Ok(()) - }) - .await - .unwrap(); - - let handler = chat_store.handler(); - handler.handle_event(Arc::new(message_event( - wa::Message::text("will fail"), - incoming_info(PEER, PEER, "MSG-F", 1_700_000_000), - ))); - let err = chat_store.flush().await.expect_err("batch must fail"); - assert!(matches!( - err, - whatsapp_rust_chat_store::ChatStoreError::WriteBatchFailed(_) - )); - - // Restore and confirm the writer survived the failure. - store - .shared() - .run(|conn| { - diesel::sql_query("ALTER TABLE messages_gone RENAME TO messages") - .execute(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - Ok(()) - }) - .await - .unwrap(); - feed( - &chat_store, - [message_event( - wa::Message::text("works again"), - incoming_info(PEER, PEER, "MSG-OK", 1_700_000_010), - )], - ) - .await; - assert!( - chat_store - .message(&jid(PEER), "MSG-OK") - .await - .unwrap() - .is_some() - ); -} - -#[cfg(feature = "search")] -#[tokio::test] -async fn fts_backfills_rows_that_predate_the_index() { - let (store, chat_store) = test_store().await; - - // Simulate a database created before the `search` feature existed: drop - // the FTS objects, then write rows with no triggers in place. - store - .shared() - .run(|conn| { - for stmt in [ - "DROP TRIGGER IF EXISTS messages_fts_ai", - "DROP TRIGGER IF EXISTS messages_fts_ad", - "DROP TRIGGER IF EXISTS messages_fts_au", - "DROP TABLE IF EXISTS messages_fts", - ] { - diesel::sql_query(stmt) - .execute(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - } - Ok(()) - }) - .await - .unwrap(); - feed( - &chat_store, - [message_event( - wa::Message::text("mensagem antiga indexável"), - incoming_info(PEER, PEER, "MSG-BF", 1_700_000_000), - )], - ) - .await; - - // A second open on the same file recreates the index and must backfill it. - let chat_store2 = ChatStore::new(&store).await.unwrap(); - let hits = chat_store2.search_messages("antiga", 10).await.unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "MSG-BF"); -} - -#[tokio::test] -async fn forever_mute_is_not_reported_as_unmuted() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("hi"), - incoming_info(PEER, PEER, "MSG-M", 1_700_000_000), - ), - Event::MuteUpdate( - wacore::types::events::MuteUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_100, 0).unwrap()) - // muted with no end timestamp = muted forever - .action(Box::new(wa::sync_action_value::MuteAction { - muted: Some(true), - ..Default::default() - })) - .from_full_sync(false) - .build(), - ), - ], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - let muted_until = chats[0].muted_until.expect("forever mute must be Some"); - assert!(muted_until > wacore::time::now_utc()); - assert_eq!(muted_until, chrono::DateTime::::MAX_UTC); -} - -#[tokio::test] -async fn clear_chat_reflects_surviving_starred_messages() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [ - message_event( - wa::Message::text("starred survivor"), - incoming_info(PEER, PEER, "MSG-S1", 1_700_000_000), - ), - message_event( - wa::Message::text("cleared away"), - incoming_info(PEER, PEER, "MSG-S2", 1_700_000_100), - ), - Event::StarUpdate( - wacore::types::events::StarUpdate::builder() - .chat_jid(chat.clone()) - .message_id("MSG-S1".to_string()) - .from_me(false) - .timestamp(Utc.timestamp_opt(1_700_000_200, 0).unwrap()) - .action(Box::new(wa::sync_action_value::StarAction { - starred: Some(true), - })) - .from_full_sync(false) - .build(), - ), - Event::ClearChatUpdate( - wacore::types::events::ClearChatUpdate::builder() - .jid(chat.clone()) - .delete_starred(false) - .delete_media(false) - .timestamp(Utc.timestamp_opt(1_700_000_300, 0).unwrap()) - .action(Box::new(wa::sync_action_value::ClearChatAction::default())) - .from_full_sync(false) - .build(), - ), - ], - ) - .await; - - // The starred message survives and becomes the preview (not a blank one - // with the deleted message's stale kind). - assert!(chat_store.message(&chat, "MSG-S1").await.unwrap().is_some()); - assert!(chat_store.message(&chat, "MSG-S2").await.unwrap().is_none()); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!( - chats[0].last_message_preview.as_deref(), - Some("starred survivor") - ); - assert_eq!(chats[0].last_message_kind, Some(MessageKind::Text)); - assert_eq!(chats[0].unread_count, 0); -} - -fn range_up_to(ts_secs: i64) -> MessageField { - MessageField::some(wa::sync_action_value::SyncActionMessageRange { - last_message_timestamp: Some(ts_secs), - ..Default::default() - }) -} - -#[tokio::test] -async fn mark_read_range_preserves_newer_unread() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("covered"), - incoming_info(PEER, PEER, "MSG-C1", 1_700_000_000), - ), - message_event( - wa::Message::text("newer than the replayed read"), - incoming_info(PEER, PEER, "MSG-C2", 1_700_000_100), - ), - ], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 2); - - // A delayed mark-read whose range ends at the first message must not - // swallow the second one's unread state. - feed( - &chat_store, - [Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(true), - message_range: range_up_to(1_700_000_000), - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn ranged_clear_and_delete_keep_newer_messages() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - // Ranged clear: only rows up to the boundary go away. - feed( - &chat_store, - [ - message_event( - wa::Message::text("old"), - incoming_info(PEER, PEER, "MSG-O", 1_700_000_000), - ), - message_event( - wa::Message::text("newer than the action"), - incoming_info(PEER, PEER, "MSG-N", 1_700_000_100), - ), - ], - ) - .await; - feed( - &chat_store, - [Event::ClearChatUpdate( - wacore::types::events::ClearChatUpdate::builder() - .jid(chat.clone()) - .delete_starred(true) - .delete_media(false) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .action(Box::new(wa::sync_action_value::ClearChatAction { - message_range: range_up_to(1_700_000_000), - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert!(chat_store.message(&chat, "MSG-O").await.unwrap().is_none()); - assert!(chat_store.message(&chat, "MSG-N").await.unwrap().is_some()); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!( - chats[0].last_message_preview.as_deref(), - Some("newer than the action") - ); - - // Ranged delete-chat: newer rows keep the chat alive. - feed( - &chat_store, - [Event::DeleteChatUpdate( - wacore::types::events::DeleteChatUpdate::builder() - .jid(chat.clone()) - .delete_media(false) - .timestamp(Utc.timestamp_opt(1_700_000_060, 0).unwrap()) - .action(Box::new(wa::sync_action_value::DeleteChatAction { - message_range: range_up_to(1_700_000_000), - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert!(chat_store.message(&chat, "MSG-N").await.unwrap().is_some()); - assert_eq!(chat_store.chats(false, 10).await.unwrap().len(), 1); - - // Unranged delete-chat: everything goes. - feed( - &chat_store, - [Event::DeleteChatUpdate( - wacore::types::events::DeleteChatUpdate::builder() - .jid(chat.clone()) - .delete_media(false) - .timestamp(Utc.timestamp_opt(1_700_000_070, 0).unwrap()) - .action(Box::new(wa::sync_action_value::DeleteChatAction::default())) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert!(chat_store.chats(false, 10).await.unwrap().is_empty()); -} - -#[tokio::test] -async fn revoke_tombstone_keeps_target_from_me() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - // Revoke of OUR OWN message (key.fromMe = true) arriving before the - // content: the tombstone must not read as incoming forever. - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-FM".into()), - from_me: Some(true), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(PEER, PEER, "MSG-FM2", 1_700_000_000), - )], - ) - .await; - let tombstone = chat_store.message(&chat, "MSG-FM").await.unwrap().unwrap(); - assert!(tombstone.revoked); - assert!(tombstone.from_me); -} - -#[tokio::test] -async fn recompute_does_not_resurrect_tombstone_kind() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [ - message_event( - wa::Message::text("older"), - incoming_info(PEER, PEER, "MSG-T1", 1_700_000_000), - ), - message_event( - wa::Message::text("newest, will be revoked"), - incoming_info(PEER, PEER, "MSG-T2", 1_700_000_100), - ), - ], - ) - .await; - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-T2".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(PEER, PEER, "MSG-T3", 1_700_000_200), - )], - ) - .await; - - // Deleting the OLDER row forces a recompute whose newest row is the - // tombstone: neither its text (None already) nor its pre-revoke kind may - // come back. - feed( - &chat_store, - [Event::DeleteMessageForMeUpdate( - wacore::types::events::DeleteMessageForMeUpdate::builder() - .chat_jid(chat.clone()) - .message_id("MSG-T1".to_string()) - .from_me(false) - .timestamp(Utc.timestamp_opt(1_700_000_300, 0).unwrap()) - .action(Box::new( - wa::sync_action_value::DeleteMessageForMeAction::default(), - )) - .from_full_sync(false) - .build(), - )], - ) - .await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert!(chats[0].last_message_preview.is_none()); - assert!(chats[0].last_message_kind.is_none()); -} - -#[tokio::test] -async fn same_millisecond_preview_follows_arrival_not_id() { - let (_store, chat_store) = test_store().await; - - // Two rows on the same millisecond, applied in an order that reverses - // their msg_id order. The preview belongs to the one that arrived last — - // the id sorts higher but says nothing about time. - feed( - &chat_store, - [ - message_event( - wa::Message::text("higher id, arrived first"), - incoming_info(PEER, PEER, "MSG-Z9", 1_700_000_000), - ), - message_event( - wa::Message::text("lower id, arrived last"), - incoming_info(PEER, PEER, "MSG-A1", 1_700_000_000), - ), - ], - ) - .await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!( - chats[0].last_message_preview.as_deref(), - Some("lower id, arrived last") - ); -} - -/// Arrival only breaks ties. A genuinely older message materialized late -/// (offline drain, history backfill) still must not take the preview. -#[tokio::test] -async fn late_but_older_message_does_not_hijack_preview() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("newest"), - incoming_info(PEER, PEER, "MSG-NEW", 1_700_000_100), - ), - message_event( - wa::Message::text("older, applied later"), - incoming_info(PEER, PEER, "MSG-OLD", 1_700_000_000), - ), - ], - ) - .await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("newest")); -} - -#[tokio::test] -async fn range_boundary_covers_the_whole_wire_second() { - let (_store, chat_store) = test_store().await; - - // 500 ms into the boundary second: the wire range (whole seconds) covers - // it, so a mark-read up to that second must clear it. - let mut info = incoming_info(PEER, PEER, "MSG-SUB", 1_700_000_000); - info.timestamp = Utc.timestamp_opt(1_700_000_000, 500_000_000).unwrap(); - feed( - &chat_store, - [message_event(wa::Message::text("sub-second"), info)], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); - - feed( - &chat_store, - [Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_001, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(true), - message_range: range_up_to(1_700_000_000), - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); -} - -#[tokio::test] -async fn keyed_range_spares_unlisted_same_second_siblings() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - // Two messages inside the SAME wire second. - for (id, text) in [("MSG-IN", "covered"), ("MSG-OUT", "not in the range")] { - feed( - &chat_store, - [message_event( - wa::Message::text(text), - incoming_info(PEER, PEER, id, 1_700_000_000), - )], - ) - .await; - } - - // The action enumerates only MSG-IN at the boundary. - let range = MessageField::some(wa::sync_action_value::SyncActionMessageRange { - last_message_timestamp: Some(1_700_000_000), - messages: vec![wa::sync_action_value::SyncActionMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-IN".into()), - remote_jid: Some(PEER.into()), - ..Default::default() - }), - timestamp: Some(1_700_000_000), - }], - ..Default::default() - }); - feed( - &chat_store, - [Event::ClearChatUpdate( - wacore::types::events::ClearChatUpdate::builder() - .jid(chat.clone()) - .delete_starred(true) - .delete_media(false) - .timestamp(Utc.timestamp_opt(1_700_000_001, 0).unwrap()) - .action(Box::new(wa::sync_action_value::ClearChatAction { - message_range: range, - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - - // Only the enumerated sibling went away; the other survives, still unread. - assert!(chat_store.message(&chat, "MSG-IN").await.unwrap().is_none()); - assert!( - chat_store - .message(&chat, "MSG-OUT") - .await - .unwrap() - .is_some() - ); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, 1); - assert_eq!( - chats[0].last_message_preview.as_deref(), - Some("not in the range") - ); -} - -#[tokio::test] -async fn ranged_clear_keeps_unread_survivors_counted() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [ - message_event( - wa::Message::text("cleared"), - incoming_info(PEER, PEER, "MSG-CL", 1_700_000_000), - ), - message_event( - wa::Message::text("unread survivor"), - incoming_info(PEER, PEER, "MSG-UN", 1_700_000_100), - ), - ], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 2); - - feed( - &chat_store, - [Event::ClearChatUpdate( - wacore::types::events::ClearChatUpdate::builder() - .jid(chat.clone()) - .delete_starred(true) - .delete_media(false) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .action(Box::new(wa::sync_action_value::ClearChatAction { - message_range: range_up_to(1_700_000_000), - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - - // The survivor is still there AND still counted as unread. - assert!(chat_store.message(&chat, "MSG-UN").await.unwrap().is_some()); - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn delayed_read_self_keeps_newer_unread() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("read on the phone"), - incoming_info(PEER, PEER, "MSG-RS1", 1_700_000_000), - ), - message_event( - wa::Message::text("arrived after the read"), - incoming_info(PEER, PEER, "MSG-RS2", 1_700_000_100), - ), - ], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 2); - - // Offline-delayed read-self covering only the FIRST message: the second - // one keeps its badge. - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(PEER), - sender: jid(PEER), - ..Default::default() - }) - .message_ids(vec!["MSG-RS1".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .r#type(ReceiptType::ReadSelf) - .offline(true) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn read_self_spares_unlisted_same_timestamp_siblings() { - let (_store, chat_store) = test_store().await; - - // Two incoming rows at the SAME stored timestamp; the receipt names one. - feed( - &chat_store, - [ - message_event( - wa::Message::text("named"), - incoming_info(PEER, PEER, "MSG-RSA", 1_700_000_000), - ), - message_event( - wa::Message::text("same instant, not named"), - incoming_info(PEER, PEER, "MSG-RSB", 1_700_000_000), - ), - ], - ) - .await; - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(PEER), - sender: jid(PEER), - ..Default::default() - }) - .message_ids(vec!["MSG-RSA".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .r#type(ReceiptType::ReadSelf) - .offline(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn stale_read_self_does_not_reinflate_the_badge() { - let (_store, chat_store) = test_store().await; - - let read_self = |ids: Vec<&str>, ts: i64| { - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(PEER), - sender: jid(PEER), - ..Default::default() - }) - .message_ids(ids.into_iter().map(String::from).collect()) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ReceiptType::ReadSelf) - .offline(true) - .build(), - ) - }; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("first"), - incoming_info(PEER, PEER, "MSG-B1", 1_700_000_000), - ), - message_event( - wa::Message::text("second"), - incoming_info(PEER, PEER, "MSG-B2", 1_700_000_100), - ), - ], - ) - .await; - - // Newest receipt clears everything... - feed( - &chat_store, - [read_self(vec!["MSG-B1", "MSG-B2"], 1_700_000_150)], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); - - // ...and a stale replay covering only the FIRST message must not - // resurrect the badge for the second. - feed(&chat_store, [read_self(vec!["MSG-B1"], 1_700_000_050)]).await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); -} - -#[tokio::test] -async fn cross_sender_id_reuse_cannot_rewrite_a_message() { - let (_store, chat_store) = test_store().await; - let chat = jid(GROUP); - let mallory = "559900000066@s.whatsapp.net"; - - feed( - &chat_store, - [message_event( - wa::Message::text("victim's original words"), - incoming_info(GROUP, PEER, "MSG-VIC", 1_700_000_000), - )], - ) - .await; - - // Message ids are sender-chosen: a different participant reusing the id - // must be deduped, never rewrite the victim's row. - feed( - &chat_store, - [message_event( - wa::Message::text("attacker rewrite"), - incoming_info(GROUP, mallory, "MSG-VIC", 1_700_000_100), - )], - ) - .await; - - let msg = chat_store.message(&chat, "MSG-VIC").await.unwrap().unwrap(); - assert_eq!(msg.text.as_deref(), Some("victim's original words")); - assert_eq!(msg.sender_jid, jid(PEER)); -} - -#[tokio::test] -async fn stale_ranged_mark_read_respects_the_read_cursor() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("first"), - incoming_info(PEER, PEER, "MSG-RC1", 1_700_000_000), - ), - message_event( - wa::Message::text("second"), - incoming_info(PEER, PEER, "MSG-RC2", 1_700_000_100), - ), - ], - ) - .await; - - // A read-self covering everything clears the badge and advances the cursor. - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(PEER), - sender: jid(PEER), - ..Default::default() - }) - .message_ids(vec!["MSG-RC1".to_string(), "MSG-RC2".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_150, 0).unwrap()) - .r#type(ReceiptType::ReadSelf) - .offline(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); - - // A STALE ranged mark-read (covers only the first message) replays later: - // it must not resurrect the second message's badge. - feed( - &chat_store, - [Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(true), - message_range: range_up_to(1_700_000_000), - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); -} - -#[tokio::test] -async fn keyed_mark_read_spares_unlisted_same_second_sibling() { - let (_store, chat_store) = test_store().await; - - // Two incoming rows in the SAME wire second; the mark-read names only one. - feed( - &chat_store, - [ - message_event( - wa::Message::text("named"), - incoming_info(PEER, PEER, "MSG-KA", 1_700_000_000), - ), - message_event( - wa::Message::text("unnamed sibling"), - incoming_info(PEER, PEER, "MSG-KB", 1_700_000_000), - ), - ], - ) - .await; - - let range = MessageField::some(wa::sync_action_value::SyncActionMessageRange { - last_message_timestamp: Some(1_700_000_000), - messages: vec![wa::sync_action_value::SyncActionMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-KA".into()), - remote_jid: Some(PEER.into()), - ..Default::default() - }), - timestamp: Some(1_700_000_000), - }], - ..Default::default() - }); - feed( - &chat_store, - [Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_001, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(true), - message_range: range, - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn late_materialized_old_message_does_not_badge_after_read() { - let (_store, chat_store) = test_store().await; - - // Unranged mark-read on an EMPTY chat: the cursor must advance off the - // action's own timestamp. - feed( - &chat_store, - [Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_100, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(true), - ..Default::default() - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - - // An OLDER message materializes afterwards (offline drain): already read, - // must not badge. - feed( - &chat_store, - [message_event( - wa::Message::text("late but old"), - incoming_info(PEER, PEER, "MSG-LATE", 1_700_000_000), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); - - // While a genuinely NEW message still badges. - feed( - &chat_store, - [message_event( - wa::Message::text("genuinely new"), - incoming_info(PEER, PEER, "MSG-NEW", 1_700_000_200), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn admin_revoke_tombstone_keeps_target_author() { - let (_store, chat_store) = test_store().await; - let chat = jid(GROUP); - let admin = "559900000077@s.whatsapp.net"; - let author = "559900000088@s.whatsapp.net"; - - // Admin revoke arriving BEFORE the original: the tombstone must attribute - // the message to its author (revoke key participant), not to the admin. - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-ADM".into()), - from_me: Some(false), - participant: Some(author.into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(GROUP, admin, "MSG-ADM2", 1_700_000_000), - )], - ) - .await; - let tombstone = chat_store.message(&chat, "MSG-ADM").await.unwrap().unwrap(); - assert!(tombstone.revoked); - assert_eq!(tombstone.sender_jid, jid(author)); -} - -#[tokio::test] -async fn redelivery_after_edit_keeps_edited_content() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - let original = || { - message_event( - wa::Message::text("original"), - incoming_info(PEER, PEER, "MSG-RED", 1_700_000_000), - ) - }; - feed(&chat_store, [original()]).await; - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-RED".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("edited"))), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - edit, - incoming_info(PEER, PEER, "MSG-RED2", 1_700_000_100), - )], - ) - .await; - - // A duplicate delivery of the PRE-edit original must not roll content back. - feed(&chat_store, [original()]).await; - let msg = chat_store.message(&chat, "MSG-RED").await.unwrap().unwrap(); - assert_eq!(msg.text.as_deref(), Some("edited")); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("edited")); -} - -#[tokio::test] -async fn late_same_instant_sibling_still_badges_after_read_self() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("named"), - incoming_info(PEER, PEER, "MSG-SI1", 1_700_000_000), - )], - ) - .await; - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(PEER), - sender: jid(PEER), - ..Default::default() - }) - .message_ids(vec!["MSG-SI1".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .r#type(ReceiptType::ReadSelf) - .offline(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); - - // An unlisted sibling at the SAME instant materializes later (offline - // drain): the receipt didn't cover it, so it must badge. - feed( - &chat_store, - [message_event( - wa::Message::text("same instant, uncovered"), - incoming_info(PEER, PEER, "MSG-SI2", 1_700_000_000), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn delete_for_me_drops_the_victims_badge() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [ - message_event( - wa::Message::text("stays unread"), - incoming_info(PEER, PEER, "MSG-U1", 1_700_000_000), - ), - message_event( - wa::Message::text("deleted while unread"), - incoming_info(PEER, PEER, "MSG-U2", 1_700_000_100), - ), - ], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 2); - - feed( - &chat_store, - [Event::DeleteMessageForMeUpdate( - wacore::types::events::DeleteMessageForMeUpdate::builder() - .chat_jid(chat.clone()) - .message_id("MSG-U2".to_string()) - .from_me(false) - .timestamp(Utc.timestamp_opt(1_700_000_200, 0).unwrap()) - .action(Box::new( - wa::sync_action_value::DeleteMessageForMeAction::default(), - )) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn keyed_read_covers_a_message_that_materializes_later() { - let (_store, chat_store) = test_store().await; - - // The keyed mark-read arrives BEFORE the message it names (read on - // another device, local drain lagging). - let range = MessageField::some(wa::sync_action_value::SyncActionMessageRange { - last_message_timestamp: Some(1_700_000_000), - messages: vec![wa::sync_action_value::SyncActionMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-FUT".into()), - remote_jid: Some(PEER.into()), - ..Default::default() - }), - timestamp: Some(1_700_000_000), - }], - ..Default::default() - }); - feed( - &chat_store, - [Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_001, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(true), - message_range: range, - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - - // The named message materializes afterwards: covered, no badge... - feed( - &chat_store, - [message_event( - wa::Message::text("read elsewhere before arriving"), - incoming_info(PEER, PEER, "MSG-FUT", 1_700_000_000), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); - - // ...while an unnamed same-second sibling still badges. - feed( - &chat_store, - [message_event( - wa::Message::text("uncovered sibling"), - incoming_info(PEER, PEER, "MSG-SIB", 1_700_000_000), - )], - ) - .await; - assert_eq!(chat_store.unread_total().await.unwrap(), 1); -} - -#[tokio::test] -async fn wire_indefinite_mute_value_reads_as_forever() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("hi"), - incoming_info(PEER, PEER, "MSG-IM", 1_700_000_000), - ), - Event::MuteUpdate( - wacore::types::events::MuteUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_100, 0).unwrap()) - // The wire's indefinite-mute sentinel (what the library's - // own mute_chat() sends). - .action(Box::new(wa::sync_action_value::MuteAction { - muted: Some(true), - mute_end_timestamp: Some(-1), - ..Default::default() - })) - .from_full_sync(false) - .build(), - ), - ], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].muted_until, Some(chrono::DateTime::::MAX_UTC)); -} - -#[tokio::test] -async fn early_tombstone_materializes_and_badges_the_chat() { - let (_store, chat_store) = test_store().await; - - // A revoke for a message we never saw, in a chat we never saw: the chat - // must still appear (the deleted message DID happen) and badge. - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-GHOST".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - revoke, - incoming_info(PEER, PEER, "MSG-GHOST2", 1_700_000_000), - )], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, jid(PEER)); - assert!(chats[0].last_message_at.is_some()); - assert!(chats[0].last_message_preview.is_none()); - assert_eq!(chats[0].unread_count, 1); -} - -fn mark_read_event(chat: &str, read: bool, ts_secs: i64) -> Event { - Event::MarkChatAsReadUpdate( - wacore::types::events::MarkChatAsReadUpdate::builder() - .jid(jid(chat)) - .timestamp(Utc.timestamp_opt(ts_secs, 0).unwrap()) - .action(Box::new(wa::sync_action_value::MarkChatAsReadAction { - read: Some(read), - ..Default::default() - })) - .from_full_sync(false) - .build(), - ) -} - -#[tokio::test] -async fn noop_mark_read_clears_manual_unread_marker() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("oi"), - incoming_info(PEER, PEER, "MSG-MU", 1_700_000_000), - )], - ) - .await; - feed(&chat_store, [mark_read_event(PEER, true, 1_700_000_010)]).await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); - - // Manually mark unread, then read the chat again: the cursor can't move - // (nothing new arrived), but the marker must still clear. - feed(&chat_store, [mark_read_event(PEER, false, 1_700_000_020)]).await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, -1); - - feed(&chat_store, [mark_read_event(PEER, true, 1_700_000_030)]).await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, 0); -} - -#[tokio::test] -async fn noop_read_self_clears_manual_unread_marker() { - let (_store, chat_store) = test_store().await; - - let read_self = |ts: i64| { - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(PEER), - sender: jid(PEER), - ..Default::default() - }) - .message_ids(vec!["MSG-RS".to_string()]) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ReceiptType::ReadSelf) - .offline(false) - .build(), - ) - }; - - feed( - &chat_store, - [message_event( - wa::Message::text("oi"), - incoming_info(PEER, PEER, "MSG-RS", 1_700_000_000), - )], - ) - .await; - feed(&chat_store, [read_self(1_700_000_010)]).await; - assert_eq!(chat_store.unread_total().await.unwrap(), 0); - - feed(&chat_store, [mark_read_event(PEER, false, 1_700_000_020)]).await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, -1); - - // Re-reading on the phone re-sends the same boundary: a no-op for the - // cursor, but the marker must still clear. - feed(&chat_store, [read_self(1_700_000_030)]).await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, 0); -} - -#[tokio::test] -async fn edit_before_target_materializes_edited_content() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - // Offline drain reordering: the edit is applied before the original. - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-EB".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("fixed"))), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - edit, - incoming_info(PEER, PEER, "MSG-EB2", 1_700_000_050), - )], - ) - .await; - - // The edited content materializes up front and badges like the original. - let msg = chat_store.message(&chat, "MSG-EB").await.unwrap().unwrap(); - assert_eq!(msg.text.as_deref(), Some("fixed")); - assert!(msg.edited_at.is_some()); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("fixed")); - assert_eq!(chats[0].unread_count, 1); - - // The original's late arrival must neither restore pre-edit text nor - // count the same message twice. - feed( - &chat_store, - [message_event( - wa::Message::text("typo"), - incoming_info(PEER, PEER, "MSG-EB", 1_700_000_000), - )], - ) - .await; - let msg = chat_store.message(&chat, "MSG-EB").await.unwrap().unwrap(); - assert_eq!(msg.text.as_deref(), Some("fixed")); - assert!(msg.edited_at.is_some()); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("fixed")); - assert_eq!(chats[0].unread_count, 1); -} - -#[tokio::test] -async fn server_nack_marks_outgoing_failed() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - chat_store - .record_outgoing( - &chat, - "OUT-NACK", - &wa::Message::text("oi"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-NACK".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .error("479".to_string()) - .build(), - )], - ) - .await; - let msg = chat_store - .message(&chat, "OUT-NACK") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Error); - - // A stray nack must not regress a message a peer already received. - chat_store - .record_outgoing( - &chat, - "OUT-READ", - &wa::Message::text("oi2"), - Utc.timestamp_opt(1_700_000_200, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - feed( - &chat_store, - [ - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: chat.clone(), - sender: chat.clone(), - ..Default::default() - }) - .message_ids(vec!["OUT-READ".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_300, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - ), - Event::ServerAck( - ServerAck::builder() - .id("OUT-READ".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .error("479".to_string()) - .build(), - ), - ], - ) - .await; - let msg = chat_store - .message(&chat, "OUT-READ") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); - - // ...nor one the server already accepted: the positive ack answered the - // stanza, so a later nack for the same id is noise. - chat_store - .record_outgoing( - &chat, - "OUT-ACKED", - &wa::Message::text("oi3"), - Utc.timestamp_opt(1_700_000_400, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - feed( - &chat_store, - [ - Event::ServerAck( - ServerAck::builder() - .id("OUT-ACKED".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .build(), - ), - Event::ServerAck( - ServerAck::builder() - .id("OUT-ACKED".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .error("479".to_string()) - .build(), - ), - ], - ) - .await; - let msg = chat_store - .message(&chat, "OUT-ACKED") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::ServerAck); -} - -// ── LID/PN identity resolution (issue #1078) ──────────────────────────────── - -const PEER_LID: &str = "111000011112222@lid"; - -/// Learn PEER <-> PEER_LID in the device store's mapping table, the alias -/// index the chat-store resolves against. -async fn add_lid_mapping(store: &SqliteStore) { - use wacore::store::traits::{LidPnMappingEntry, ProtocolStore}; - // The mapping table's FK needs the device row the client normally creates. - store.create_new_device().await.expect("create device"); - store - .put_lid_mapping(&LidPnMappingEntry { - lid: "111000011112222".into(), - phone_number: "559900000001".into(), - created_at: 1_700_000_000, - updated_at: 1_700_000_000, - learning_source: "usync".into(), - }) - .await - .expect("put mapping"); -} - -fn read_receipt(chat: &str, ids: Vec<&str>, ts: i64) -> Event { - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(chat), - sender: jid(chat), - ..Default::default() - }) - .message_ids(ids.into_iter().map(String::from).collect()) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - ) -} - -/// The issue #1078 scenario: rows stored under the phone-number key before -/// any mapping was known, delivered/read receipts arriving LID-keyed. -#[tokio::test] -async fn lid_receipt_advances_pn_keyed_rows() { - let (store, chat_store) = test_store().await; - let chat = jid(PEER); - - chat_store - .record_outgoing( - &chat, - "OUT-SPLIT", - &wa::Message::text("oi"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-SPLIT".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .build(), - )], - ) - .await; - - add_lid_mapping(&store).await; - feed( - &chat_store, - [read_receipt(PEER_LID, vec!["OUT-SPLIT"], 1_700_000_200)], - ) - .await; - - let msg = chat_store - .message(&chat, "OUT-SPLIT") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); - // No stray @lid twin was created by the receipt. - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, chat); - // Either identity reads the same thread. - let via_lid = chat_store - .message(&jid(PEER_LID), "OUT-SPLIT") - .await - .unwrap() - .unwrap(); - assert_eq!(via_lid.status, MessageStatus::Read); -} - -/// The mirror direction: LID-keyed rows, PN-keyed receipt. -#[tokio::test] -async fn pn_receipt_advances_lid_keyed_rows() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER_LID), - "OUT-L", - &wa::Message::text("oi"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - add_lid_mapping(&store).await; - feed( - &chat_store, - [read_receipt(PEER, vec!["OUT-L"], 1_700_000_200)], - ) - .await; - - let msg = chat_store - .message(&jid(PEER_LID), "OUT-L") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); -} - -/// Without a mapping the receipt still can't be attributed (the pre-fix -/// behavior); once the mapping is learned, a replayed receipt heals the row. -#[tokio::test] -async fn receipt_heals_once_mapping_is_learned() { - let (store, chat_store) = test_store().await; - let chat = jid(PEER); - - chat_store - .record_outgoing( - &chat, - "OUT-H", - &wa::Message::text("oi"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - feed( - &chat_store, - [read_receipt(PEER_LID, vec!["OUT-H"], 1_700_000_200)], - ) - .await; - let msg = chat_store.message(&chat, "OUT-H").await.unwrap().unwrap(); - assert_eq!(msg.status, MessageStatus::Pending); - - add_lid_mapping(&store).await; - feed( - &chat_store, - [read_receipt(PEER_LID, vec!["OUT-H"], 1_700_000_300)], - ) - .await; - let msg = chat_store.message(&chat, "OUT-H").await.unwrap().unwrap(); - assert_eq!(msg.status, MessageStatus::Read); -} - -/// With the mapping known up front, a brand-new chat is keyed by the LID (WA -/// Web `selectChatForOneOnOneMessage`) even when addressed by phone number, -/// so later LID-keyed receipts hit exactly. -#[tokio::test] -async fn known_mapping_keys_new_chat_by_lid() { - let (store, chat_store) = test_store().await; - add_lid_mapping(&store).await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-NEW", - &wa::Message::text("primeira"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - feed( - &chat_store, - [read_receipt(PEER_LID, vec!["OUT-NEW"], 1_700_000_200)], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, jid(PEER_LID)); - // The embedder still addresses (and reads) by phone number. - let msg = chat_store - .message(&jid(PEER), "OUT-NEW") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); -} - -/// An inbound LID-addressed message joins the peer's existing PN-keyed -/// thread instead of opening a twin chat. -#[tokio::test] -async fn inbound_lid_message_joins_existing_pn_thread() { - let (store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("antes"), - incoming_info(PEER, PEER, "MSG-P1", 1_700_000_000), - )], - ) - .await; - add_lid_mapping(&store).await; - feed( - &chat_store, - [message_event( - wa::Message::text("depois"), - incoming_info(PEER_LID, PEER_LID, "MSG-L1", 1_700_000_100), - )], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, jid(PEER)); - assert_eq!(chats[0].unread_count, 2); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("depois")); - let messages = chat_store.messages(&jid(PEER_LID), None, 10).await.unwrap(); - assert_eq!(messages.len(), 2); -} - -/// A read-self receipt arriving LID-keyed clears the PN-keyed thread's badge -/// instead of materializing an empty @lid chat row. -#[tokio::test] -async fn lid_read_self_clears_pn_thread_badge() { - let (store, chat_store) = test_store().await; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("um"), - incoming_info(PEER, PEER, "MSG-RS-A", 1_700_000_000), - ), - message_event( - wa::Message::text("dois"), - incoming_info(PEER, PEER, "MSG-RS-B", 1_700_000_010), - ), - ], - ) - .await; - add_lid_mapping(&store).await; - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(PEER_LID), - sender: jid(PEER_LID), - ..Default::default() - }) - .message_ids(vec!["MSG-RS-A".to_string(), "MSG-RS-B".to_string()]) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .r#type(ReceiptType::ReadSelf) - .offline(false) - .build(), - )], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, jid(PEER)); - assert_eq!(chats[0].unread_count, 0); -} - -/// Splits left behind by the pre-fix behavior merge on demand: messages fold -/// into the newer-activity side, the badge is recounted, sticky prefs -/// survive, and the repair is idempotent. -#[tokio::test] -async fn reconcile_merges_split_pair() { - let (store, chat_store) = test_store().await; - - // No mapping yet: two independent chats form (the split). - feed( - &chat_store, - [ - message_event( - wa::Message::text("via pn"), - incoming_info(PEER, PEER, "MSG-SP-A", 1_700_000_000), - ), - Event::PinUpdate( - wacore::types::events::PinUpdate::builder() - .jid(jid(PEER)) - .timestamp(Utc.timestamp_opt(1_700_000_050, 0).unwrap()) - .action(Box::new(wa::sync_action_value::PinAction { - pinned: Some(true), - })) - .from_full_sync(false) - .build(), - ), - message_event( - wa::Message::text("via lid"), - incoming_info(PEER_LID, PEER_LID, "MSG-SP-B", 1_700_000_100), - ), - ], - ) - .await; - assert_eq!(chat_store.chats(false, 10).await.unwrap().len(), 2); - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - // Newest activity was on the LID side, so that key survives. - assert_eq!(chats[0].jid, jid(PEER_LID)); - assert_eq!(chats[0].unread_count, 2); - assert!(chats[0].pinned_at.is_some()); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("via lid")); - let messages = chat_store.messages(&jid(PEER), None, 10).await.unwrap(); - assert_eq!(messages.len(), 2); - - // Idempotent. - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].unread_count, 2); -} - -/// The same message stored under both keys keeps the most advanced status -/// after the merge. -#[tokio::test] -async fn merge_advances_duplicate_row_status() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-DUP", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_outgoing( - &jid(PEER_LID), - "OUT-DUP", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - // Only the LID copy saw the read receipt. - feed( - &chat_store, - [read_receipt(PEER_LID, vec!["OUT-DUP"], 1_700_000_200)], - ) - .await; - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - // PN side had the newer activity, so it is the surviving key… - assert_eq!(chats[0].jid, jid(PEER)); - // …and the duplicate's Read status survived onto it. - let msg = chat_store - .message(&jid(PEER), "OUT-DUP") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); -} - -/// Both identities recorded the same state before the mapping reconciled. The -/// merge direction is decided by chat activity, which says nothing about which -/// side saw the receipt first, so the earlier instant has to be carried over -/// rather than left to whichever key wins. -#[tokio::test] -async fn merge_keeps_the_earlier_instant_for_a_state_both_sides_recorded() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-TS", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_outgoing( - &jid(PEER_LID), - "OUT-TS", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - // One sender, addressing the thread by each of its identities in turn — - // the shape of a PN/LID transition, and the only way the same - // (message, user, state) lands under both chat keys. The LID side saw the - // read first; the PN side, which newer activity will make the merge - // destination, saw it later. - let read_from_lid_addressed_to = |chat: &str, ts: i64| { - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(chat), - sender: jid(PEER_LID), - ..Default::default() - }) - .message_ids(vec!["OUT-TS".to_string()]) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - ) - }; - feed( - &chat_store, - [ - read_from_lid_addressed_to(PEER_LID, 1_700_000_200), - read_from_lid_addressed_to(PEER, 1_700_000_800), - ], - ) - .await; - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let receipts = chat_store.receipts(&jid(PEER), "OUT-TS").await.unwrap(); - assert_eq!(receipts.len(), 1, "{receipts:?}"); - assert_eq!( - receipts[0].timestamp.timestamp(), - 1_700_000_200, - "the losing side saw it first, so its instant is the true one" - ); -} - -/// One peer, not two. A 1:1's receipt names whoever the peer sent from, so a -/// thread that changed identity mid-flight accumulates rows under both — and -/// the merge is the only place that can put them back together, since moving -/// `chat_jid` leaves `user_jid` untouched. -#[tokio::test] -async fn merge_folds_a_split_peer_identity_into_one_user() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-WHO", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_outgoing( - &jid(PEER_LID), - "OUT-WHO", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - // Delivered reported from the LID identity, read from the PN one. - feed( - &chat_store, - [ - peer_receipt( - jid(PEER_LID), - vec!["OUT-WHO"], - ReceiptType::Delivered, - 1_700_000_200, - ), - peer_receipt(jid(PEER), vec!["OUT-WHO"], ReceiptType::Read, 1_700_000_300), - ], - ) - .await; - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let receipts = chat_store.receipts(&jid(PEER), "OUT-WHO").await.unwrap(); - let mut users: Vec = receipts.iter().map(|r| r.user_jid.to_string()).collect(); - users.dedup(); - assert_eq!( - users, - vec![PEER], - "both states belong to one peer after the merge: {receipts:?}" - ); - assert_eq!( - receipts - .iter() - .map(|r| (r.status, r.timestamp.timestamp())) - .collect::>(), - vec![ - (MessageStatus::Delivered, 1_700_000_200), - (MessageStatus::Read, 1_700_000_300), - ], - "and both states survive: {receipts:?}" - ); -} - -/// The collision the identity rewrite cannot resolve by itself: both -/// identities recorded the *same* state, and one of the rows already sits -/// under the surviving key. Renaming it would duplicate the row that is -/// already there, so it is skipped — and the `chat_jid = src` sweep never -/// reaches it, because it was never filed under `src`. -#[tokio::test] -async fn merge_drops_the_twin_left_by_a_same_state_collision() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-TWIN", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_outgoing( - &jid(PEER_LID), - "OUT-TWIN", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let read_from = |sender: &str, chat: &str, ts: i64| { - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(chat), - sender: jid(sender), - ..Default::default() - }) - .message_ids(vec!["OUT-TWIN".to_string()]) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - ) - }; - // Same state, same surviving chat, two identities — and the retiring - // identity is the one that saw it first. - feed( - &chat_store, - [ - read_from(PEER_LID, PEER, 1_700_000_200), - read_from(PEER, PEER, 1_700_000_800), - ], - ) - .await; - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let receipts = chat_store.receipts(&jid(PEER), "OUT-TWIN").await.unwrap(); - assert_eq!( - receipts.len(), - 1, - "the skipped twin must not outlive the merge: {receipts:?}" - ); - assert_eq!(receipts[0].user_jid, jid(PEER)); - assert_eq!( - receipts[0].timestamp.timestamp(), - 1_700_000_200, - "and it leaves its instant behind" - ); -} - -/// The same collision from the retiring side. Here the skipped row sits under -/// `src`, so the dedup sweep must reach it before the chat rename does — -/// otherwise the rename carries it to the surviving thread untouched, still -/// naming the identity being retired. -#[tokio::test] -async fn merge_drops_a_same_state_collision_left_on_the_retiring_side() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-SRC-TWIN", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store - .record_outgoing( - &jid(PEER_LID), - "OUT-SRC-TWIN", - &wa::Message::text("dup"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let read_from = |sender: &str, chat: &str, ts: i64| { - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: jid(chat), - sender: jid(sender), - ..Default::default() - }) - .message_ids(vec!["OUT-SRC-TWIN".to_string()]) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ReceiptType::Read) - .offline(false) - .build(), - ) - }; - // Both identities record the same state under the LID chat — the side that - // newer PN activity will retire. - feed( - &chat_store, - [ - read_from(PEER_LID, PEER_LID, 1_700_000_800), - read_from(PEER, PEER_LID, 1_700_000_200), - ], - ) - .await; - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let receipts = chat_store - .receipts(&jid(PEER), "OUT-SRC-TWIN") - .await - .unwrap(); - assert_eq!( - receipts.len(), - 1, - "the collision survivor must not ride the rename over: {receipts:?}" - ); - assert_eq!(receipts[0].user_jid, jid(PEER)); - assert_eq!( - receipts[0].timestamp.timestamp(), - 1_700_000_200, - "and the earlier instant survives" - ); -} - -/// A reaction addressed by the peer's other identity lands on the stored -/// message (routing picks the existing thread). -#[tokio::test] -async fn lid_reaction_reaches_pn_keyed_message() { - let (store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("alvo"), - incoming_info(PEER, PEER, "MSG-R1", 1_700_000_000), - )], - ) - .await; - add_lid_mapping(&store).await; - - let reaction = wa::Message { - reaction_message: MessageField::some(wa::message::ReactionMessage { - key: MessageField::some(wa::MessageKey { - remote_jid: Some(PEER_LID.to_string()), - from_me: Some(false), - id: Some("MSG-R1".to_string()), - ..Default::default() - }), - text: Some("👍".to_string()), - sender_timestamp_ms: Some(1_700_000_100_000), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [message_event( - reaction, - incoming_info(PEER_LID, PEER_LID, "MSG-R1-REACT", 1_700_000_100), - )], - ) - .await; - - let reactions = chat_store.reactions(&jid(PEER), "MSG-R1").await.unwrap(); - assert_eq!(reactions.len(), 1); - assert_eq!(reactions[0].emoji, "👍"); - // No twin chat was opened for the reaction. - assert_eq!(chat_store.chats(false, 10).await.unwrap().len(), 1); -} - -/// An edit or revoke that reached only one side of a split survives the -/// merge: the source copy's newer edit content and tombstone fold into the -/// surviving row (same monotonic rules as the live path). -#[tokio::test] -async fn merge_folds_src_side_edit_and_revoke() { - let (store, chat_store) = test_store().await; - - // No mapping: duplicate copies of both messages under each identity. - for chat in [PEER, PEER_LID] { - feed( - &chat_store, - [ - message_event( - wa::Message::text("typo"), - incoming_info(chat, chat, "MSG-ED", 1_700_000_000), - ), - message_event( - wa::Message::text("apaga"), - incoming_info(chat, chat, "MSG-RV", 1_700_000_010), - ), - ], - ) - .await; - } - // Edit and revoke land only on the LID side. - let edit = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-ED".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text("consertada"))), - ..Default::default() - }), - ..Default::default() - }; - let revoke = wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some("MSG-RV".into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::REVOKE), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [ - message_event( - edit, - incoming_info(PEER_LID, PEER_LID, "MSG-ED2", 1_700_000_050), - ), - message_event( - revoke, - incoming_info(PEER_LID, PEER_LID, "MSG-RV2", 1_700_000_060), - ), - // Newer activity on the PN side makes it the merge destination. - message_event( - wa::Message::text("mais nova"), - incoming_info(PEER, PEER, "MSG-NW", 1_700_000_100), - ), - ], - ) - .await; - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, jid(PEER)); - let edited = chat_store - .message(&jid(PEER), "MSG-ED") - .await - .unwrap() - .unwrap(); - assert_eq!(edited.text.as_deref(), Some("consertada")); - assert!(edited.edited_at.is_some()); - let revoked = chat_store - .message(&jid(PEER), "MSG-RV") - .await - .unwrap() - .unwrap(); - assert!(revoked.revoked); - assert!(revoked.text.is_none()); -} - -/// Competing edits on both copies of the same message: the strictly newer -/// edit wins the merge in either direction. -#[tokio::test] -async fn merge_keeps_strictly_newer_edit_across_sides() { - let (store, chat_store) = test_store().await; - - // No mapping: duplicate copies under each identity, then both sides edit. - for chat in [PEER, PEER_LID] { - feed( - &chat_store, - [ - message_event( - wa::Message::text("v0-a"), - incoming_info(chat, chat, "MSG-CE1", 1_700_000_000), - ), - message_event( - wa::Message::text("v0-b"), - incoming_info(chat, chat, "MSG-CE2", 1_700_000_010), - ), - ], - ) - .await; - } - let edit = |target: &str, text: &str| wa::Message { - protocol_message: MessageField::some(wa::message::ProtocolMessage { - key: MessageField::some(wa::MessageKey { - id: Some(target.into()), - ..Default::default() - }), - r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), - edited_message: MessageField::from_box(Box::new(wa::Message::text(text))), - ..Default::default() - }), - ..Default::default() - }; - feed( - &chat_store, - [ - // CE1: the PN (destination) side carries the newer edit. - message_event( - edit("MSG-CE1", "lid antiga"), - incoming_info(PEER_LID, PEER_LID, "MSG-CE1-EL", 1_700_000_050), - ), - message_event( - edit("MSG-CE1", "pn mais nova"), - incoming_info(PEER, PEER, "MSG-CE1-EP", 1_700_000_080), - ), - // CE2: the LID (source) side carries the newer edit. - message_event( - edit("MSG-CE2", "pn antiga"), - incoming_info(PEER, PEER, "MSG-CE2-EP", 1_700_000_050), - ), - message_event( - edit("MSG-CE2", "lid mais nova"), - incoming_info(PEER_LID, PEER_LID, "MSG-CE2-EL", 1_700_000_080), - ), - // Newest activity keeps the PN side as merge destination. - message_event( - wa::Message::text("mais nova"), - incoming_info(PEER, PEER, "MSG-CE-NW", 1_700_000_100), - ), - ], - ) - .await; - - add_lid_mapping(&store).await; - chat_store.reconcile_chat(&jid(PEER)).unwrap(); - chat_store.flush().await.unwrap(); - - let ce1 = chat_store - .message(&jid(PEER), "MSG-CE1") - .await - .unwrap() - .unwrap(); - assert_eq!(ce1.text.as_deref(), Some("pn mais nova")); - assert_eq!( - ce1.edited_at.map(|t| t.timestamp()), - Some(1_700_000_080), - "destination's newer edit must not be clobbered by the source's older one" - ); - let ce2 = chat_store - .message(&jid(PEER), "MSG-CE2") - .await - .unwrap() - .unwrap(); - assert_eq!(ce2.text.as_deref(), Some("lid mais nova")); - assert_eq!(ce2.edited_at.map(|t| t.timestamp()), Some(1_700_000_080)); -} - -// ── Companion-device (device-suffixed) identities (issue #1095) ───────────── - -/// A peer's linked device as the binary decoder yields it: `user:48@lid`, -/// carrying the LID domain-type byte in `agent`. -fn companion(user: &str, device: u16) -> Jid { - Jid { - user: user.into(), - server: wacore_binary::Server::Lid, - agent: 1, - device, - integrator: 0, - } -} - -fn peer_receipt(source: Jid, ids: Vec<&str>, ty: ReceiptType, ts: i64) -> Event { - Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: source.clone(), - sender: source, - ..Default::default() - }) - .message_ids(ids.into_iter().map(String::from).collect()) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ty) - .offline(false) - .build(), - ) -} - -/// A fresh LID-only thread has no counterpart to fall back to, so the direct -/// match has to succeed on the normalized key. -#[tokio::test] -async fn companion_device_receipt_advances_status() { - let (_store, chat_store) = test_store().await; - let chat = jid("10203040506070@lid"); - - chat_store - .record_outgoing( - &chat, - "OUT-AD-1", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - feed( - &chat_store, - [peer_receipt( - companion("10203040506070", 48), - vec!["OUT-AD-1"], - ReceiptType::Delivered, - 1_700_000_200, - )], - ) - .await; - - let msg = chat_store - .message(&chat, "OUT-AD-1") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Delivered); - // The receipt keyed no thread of its own. - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, chat); -} - -/// Multi-device emits the read once, from whichever device read first. The -/// primary never re-sends it, so a companion read has to land. -#[tokio::test] -async fn companion_read_advances_past_primary_delivered() { - let (_store, chat_store) = test_store().await; - let chat = jid("10203040506070@lid"); - - chat_store - .record_outgoing( - &chat, - "OUT-AD-2", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - feed( - &chat_store, - [ - peer_receipt( - chat.clone(), - vec!["OUT-AD-2"], - ReceiptType::Delivered, - 1_700_000_200, - ), - peer_receipt( - companion("10203040506070", 48), - vec!["OUT-AD-2"], - ReceiptType::Read, - 1_700_000_300, - ), - ], - ) - .await; - - let msg = chat_store - .message(&chat, "OUT-AD-2") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); -} - -/// Device-suffixed *and* addressed by the identity the rows are not keyed -/// under: normalization has to happen before the alternate-key retry, or the -/// mapping is never consulted. -#[tokio::test] -async fn companion_receipt_resolves_across_pn_lid_mapping() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-AD-3", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - add_lid_mapping(&store).await; - - feed( - &chat_store, - [peer_receipt( - companion("111000011112222", 12), - vec!["OUT-AD-3"], - ReceiptType::Read, - 1_700_000_200, - )], - ) - .await; - - let msg = chat_store - .message(&jid(PEER), "OUT-AD-3") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].jid, jid(PEER)); -} - -/// A 1:1 keeps its receipt rows, so a reader can say *when* the peer got and -/// read the message and not merely that they did. `messages.status` carries the -/// state it reached and no instant, which is the half WA Web's contact message -/// info renders as "Delivered hh:mm" above "Read hh:mm". -#[tokio::test] -async fn dm_receipts_record_when_each_state_was_reached() { - let (_store, chat_store) = test_store().await; - let peer = jid(PEER); - - chat_store - .record_outgoing( - &peer, - "OUT-DM-INFO", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - feed( - &chat_store, - [ - peer_receipt( - peer.clone(), - vec!["OUT-DM-INFO"], - ReceiptType::Delivered, - 1_700_000_200, - ), - peer_receipt( - peer.clone(), - vec!["OUT-DM-INFO"], - ReceiptType::Read, - 1_700_000_300, - ), - ], - ) - .await; - - let receipts = chat_store.receipts(&peer, "OUT-DM-INFO").await.unwrap(); - assert_eq!( - receipts - .iter() - .map(|r| (r.user_jid.clone(), r.status, r.timestamp.timestamp())) - .collect::>(), - vec![ - (peer.clone(), MessageStatus::Delivered, 1_700_000_200), - (peer.clone(), MessageStatus::Read, 1_700_000_300), - ], - "both instants survive: {receipts:?}" - ); - - // The state on the message itself is unchanged by any of this. - let msg = chat_store - .message(&peer, "OUT-DM-INFO") - .await - .unwrap() - .unwrap(); - assert_eq!(msg.status, MessageStatus::Read); -} - -/// A voice note's `played` is a third state, not a replacement for `read`. -#[tokio::test] -async fn dm_played_receipt_joins_read_rather_than_replacing_it() { - let (_store, chat_store) = test_store().await; - let peer = jid(PEER); - - chat_store - .record_outgoing( - &peer, - "OUT-DM-PTT", - &wa::Message::text("ptt"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - for (ty, ts) in [ - (ReceiptType::Delivered, 1_700_000_200), - (ReceiptType::Read, 1_700_000_300), - (ReceiptType::Played, 1_700_000_400), - ] { - feed( - &chat_store, - [peer_receipt(peer.clone(), vec!["OUT-DM-PTT"], ty, ts)], - ) - .await; - } - - let receipts = chat_store.receipts(&peer, "OUT-DM-PTT").await.unwrap(); - assert_eq!( - receipts - .iter() - .map(|r| (r.status, r.timestamp.timestamp())) - .collect::>(), - vec![ - (MessageStatus::Delivered, 1_700_000_200), - (MessageStatus::Read, 1_700_000_300), - (MessageStatus::Played, 1_700_000_400), - ], - "{receipts:?}" - ); -} - -/// A replayed receipt is a duplicate, not a later event: the instant a state -/// was first reported is the one that stays. -#[tokio::test] -async fn a_replayed_dm_receipt_does_not_move_the_recorded_instant() { - let (_store, chat_store) = test_store().await; - let peer = jid(PEER); - - chat_store - .record_outgoing( - &peer, - "OUT-DM-DUP", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - for ts in [1_700_000_200, 1_700_000_900] { - feed( - &chat_store, - [peer_receipt( - peer.clone(), - vec!["OUT-DM-DUP"], - ReceiptType::Delivered, - ts, - )], - ) - .await; - } - - let receipts = chat_store.receipts(&peer, "OUT-DM-DUP").await.unwrap(); - assert_eq!(receipts.len(), 1, "{receipts:?}"); - assert_eq!(receipts[0].timestamp.timestamp(), 1_700_000_200); -} - -/// A receipt that only answers under the counterpart identity must file its -/// row there too. The satellite prune is per chat and collects receipt rows -/// whose message is absent from that chat, so a row left behind under the wire -/// key would not survive the next trim. -#[tokio::test] -async fn a_dm_receipt_resolved_by_alias_files_under_the_message_key() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-DM-ALIAS", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - add_lid_mapping(&store).await; - - // Addressed by LID while the row is keyed by PN. - feed( - &chat_store, - [peer_receipt( - jid(PEER_LID), - vec!["OUT-DM-ALIAS"], - ReceiptType::Read, - 1_700_000_200, - )], - ) - .await; - - // Reachable under either identity, since the reader resolves the alias. - for addressed_as in [PEER, PEER_LID] { - let receipts = chat_store - .receipts(&jid(addressed_as), "OUT-DM-ALIAS") - .await - .unwrap(); - assert_eq!(receipts.len(), 1, "as {addressed_as}: {receipts:?}"); - assert_eq!(receipts[0].status, MessageStatus::Read); - assert_eq!(receipts[0].timestamp.timestamp(), 1_700_000_200); - } - - // Filed under the key the message actually lives at, not the wire key it - // arrived addressed to — which is what keeps the per-chat satellite prune - // from collecting it as an orphan. - let stored: Vec = store - .shared() - .run(|conn| { - diesel::sql_query( - "SELECT chat_jid AS jid FROM message_receipts \ - WHERE device_id = 1 AND msg_id = 'OUT-DM-ALIAS'", - ) - .load(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e))) - }) - .await - .unwrap(); - assert_eq!( - stored.iter().map(|r| r.jid.as_str()).collect::>(), - vec![PEER], - "receipt follows the message's key, not the wire key" - ); -} - -/// A receipt that advances nothing still has to file under the message's key. -/// The status not moving says the state was already reached, not that the row -/// lives somewhere else — so ownership cannot be read off the update count. -#[tokio::test] -async fn a_dm_receipt_behind_the_current_status_still_files_by_alias() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-DM-BEHIND", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - add_lid_mapping(&store).await; - - // Read first, then a Delivered that arrives late: it advances nothing, - // because the message is already further along. - feed( - &chat_store, - [ - peer_receipt( - jid(PEER_LID), - vec!["OUT-DM-BEHIND"], - ReceiptType::Read, - 1_700_000_300, - ), - peer_receipt( - jid(PEER_LID), - vec!["OUT-DM-BEHIND"], - ReceiptType::Delivered, - 1_700_000_200, - ), - ], - ) - .await; - - let stored: Vec = store - .shared() - .run(|conn| { - diesel::sql_query( - "SELECT DISTINCT chat_jid AS jid FROM message_receipts \ - WHERE device_id = 1 AND msg_id = 'OUT-DM-BEHIND'", - ) - .load(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e))) - }) - .await - .unwrap(); - assert_eq!( - stored.iter().map(|r| r.jid.as_str()).collect::>(), - vec![PEER], - "the late receipt filed under the wire key instead of the message's" - ); - - let receipts = chat_store - .receipts(&jid(PEER), "OUT-DM-BEHIND") - .await - .unwrap(); - assert_eq!( - receipts - .iter() - .map(|r| (r.status, r.timestamp.timestamp())) - .collect::>(), - vec![ - (MessageStatus::Delivered, 1_700_000_200), - (MessageStatus::Read, 1_700_000_300), - ], - "{receipts:?}" - ); -} - -/// Receipts do not arrive in time order — an offline queue drains after the -/// live socket — so the state's instant is the earliest reported, not the -/// first one processed. -#[tokio::test] -async fn an_out_of_order_receipt_lowers_the_recorded_instant() { - let (_store, chat_store) = test_store().await; - let peer = jid(PEER); - - chat_store - .record_outgoing( - &peer, - "OUT-DM-ORDER", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - // The live device reports first, then a delayed report of the same state - // that actually happened earlier. - for ts in [1_700_000_900, 1_700_000_200] { - feed( - &chat_store, - [peer_receipt( - peer.clone(), - vec!["OUT-DM-ORDER"], - ReceiptType::Delivered, - ts, - )], - ) - .await; - } - - let receipts = chat_store.receipts(&peer, "OUT-DM-ORDER").await.unwrap(); - assert_eq!(receipts.len(), 1, "{receipts:?}"); - assert_eq!( - receipts[0].timestamp.timestamp(), - 1_700_000_200, - "the earlier instant wins regardless of arrival order" - ); -} - -/// A receipt naming a message no chat holds is dropped, not parked. The id is -/// the server's, and nothing here can tell an unrecorded send from a message -/// the user deleted — so parking one re-created metadata for messages that -/// were deliberately removed. -#[tokio::test] -async fn a_receipt_for_a_message_no_chat_holds_is_dropped() { - let (_store, chat_store) = test_store().await; - let peer = jid(PEER); - - feed( - &chat_store, - [peer_receipt( - peer.clone(), - vec!["OUT-DM-UNKNOWN"], - ReceiptType::Delivered, - 1_700_000_200, - )], - ) - .await; - - assert!( - chat_store - .receipts(&peer, "OUT-DM-UNKNOWN") - .await - .unwrap() - .is_empty(), - "nothing owns this id, so nothing is recorded for it" - ); -} - -/// The case that motivates dropping: a delete removes a message and sweeps its -/// receipts, then a delayed or replayed receipt for it arrives. It must not -/// bring the deleted message's metadata back. -#[tokio::test] -async fn a_receipt_arriving_after_a_delete_does_not_resurrect_it() { - let (_store, chat_store) = test_store().await; - let peer = jid(PEER); - - chat_store - .record_outgoing( - &peer, - "OUT-DM-GONE", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - feed( - &chat_store, - [peer_receipt( - peer.clone(), - vec!["OUT-DM-GONE"], - ReceiptType::Delivered, - 1_700_000_200, - )], - ) - .await; - assert_eq!( - chat_store - .receipts(&peer, "OUT-DM-GONE") - .await - .unwrap() - .len(), - 1 - ); - - feed( - &chat_store, - [Event::ClearChatUpdate( - wacore::types::events::ClearChatUpdate::builder() - .jid(peer.clone()) - .delete_starred(true) - .delete_media(false) - .timestamp(Utc.timestamp_opt(1_700_000_300, 0).unwrap()) - .action(Box::new(wa::sync_action_value::ClearChatAction { - message_range: None.into(), - })) - .from_full_sync(false) - .build(), - )], - ) - .await; - assert!( - chat_store - .message(&peer, "OUT-DM-GONE") - .await - .unwrap() - .is_none() - ); - - // The peer's other device reports the same state, late. - feed( - &chat_store, - [peer_receipt( - peer.clone(), - vec!["OUT-DM-GONE"], - ReceiptType::Read, - 1_700_000_400, - )], - ) - .await; - - assert!( - chat_store - .receipts(&peer, "OUT-DM-GONE") - .await - .unwrap() - .is_empty(), - "a deleted message stays deleted, metadata and all" - ); -} - -/// Dropping the unowned ones must not cost the aliased ones: a receipt -/// addressed by one identity for a message stored under the other still files -/// against the message. -#[tokio::test] -async fn an_aliased_receipt_still_files_against_its_message() { - let (store, chat_store) = test_store().await; - - chat_store - .record_outgoing( - &jid(PEER), - "OUT-DM-ALIASED", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - add_lid_mapping(&store).await; - - feed( - &chat_store, - [peer_receipt( - jid(PEER_LID), - vec!["OUT-DM-ALIASED"], - ReceiptType::Delivered, - 1_700_000_200, - )], - ) - .await; - - let stored: Vec = store - .shared() - .run(|conn| { - diesel::sql_query( - "SELECT chat_jid AS jid FROM message_receipts \ - WHERE device_id = 1 AND msg_id = 'OUT-DM-ALIASED'", - ) - .load(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e))) - }) - .await - .unwrap(); - assert_eq!( - stored.iter().map(|r| r.jid.as_str()).collect::>(), - vec![PEER], - "filed where the message lives" - ); -} - -/// A self receipt carrying a device must recount the real thread instead of -/// materializing a twin of it. -#[tokio::test] -async fn companion_read_self_recounts_the_real_chat() { - let (_store, chat_store) = test_store().await; - let chat = jid("10203040506070@lid"); - - feed( - &chat_store, - [message_event( - wa::Message::text("oi"), - incoming_info( - "10203040506070@lid", - "10203040506070@lid", - "IN-AD-1", - 1_700_000_000, - ), - )], - ) - .await; - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].unread_count, 1); - - feed( - &chat_store, - [peer_receipt( - companion("10203040506070", 48), - vec!["IN-AD-1"], - ReceiptType::ReadSelf, - 1_700_000_100, - )], - ) - .await; - - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats.len(), 1, "no phantom chat: {chats:?}"); - assert_eq!(chats[0].jid, chat); - assert_eq!(chats[0].unread_count, 0); -} - -/// Messages keep the device on `sender` by design, so the push name of a peer -/// texting from WhatsApp Web has to be filed under the bare identity anyway. -#[tokio::test] -async fn companion_sender_push_name_lands_on_the_bare_contact() { - let (_store, chat_store) = test_store().await; - let bare = jid("10203040506070@lid"); - let device = companion("10203040506070", 48); - - let mut info = MessageInfo { - source: MessageSource { - chat: bare.clone(), - sender: device.clone(), - is_from_me: false, - ..Default::default() - }, - id: "IN-AD-2".to_string(), - timestamp: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ..Default::default() - }; - info.push_name = "Alice Example".into(); - feed(&chat_store, [message_event(wa::Message::text("oi"), info)]).await; - - let contact = chat_store.contact(&bare).await.unwrap().unwrap(); - assert_eq!(contact.push_name.as_deref(), Some("Alice Example")); - // And a caller holding the message's `sender` finds the same row. - let via_device = chat_store.contact(&device).await.unwrap().unwrap(); - assert_eq!(via_device.jid, bare); -} - -/// Receipts collapse by participant, not by device: a member reading on their -/// phone and on Web emits one receipt each, and both name the same person. -/// The two rows that survive are that person's two *states*, not two members. -#[tokio::test] -async fn group_receipts_from_two_devices_keep_one_participant() { - let (_store, chat_store) = test_store().await; - let group = jid(GROUP); - - chat_store - .record_outgoing( - &group, - "OUT-G-AD", - &wa::Message::text("olá"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - for (device, ty, ts) in [ - (0u16, ReceiptType::Delivered, 1_700_000_200), - (48u16, ReceiptType::Read, 1_700_000_300), - ] { - feed( - &chat_store, - [Event::Receipt( - Receipt::builder() - .source(MessageSource { - chat: group.clone(), - sender: companion("111000011112222", device), - ..Default::default() - }) - .message_ids(vec!["OUT-G-AD".to_string()]) - .timestamp(Utc.timestamp_opt(ts, 0).unwrap()) - .r#type(ty) - .offline(false) - .build(), - )], - ) - .await; - } - - let receipts = chat_store.receipts(&group, "OUT-G-AD").await.unwrap(); - let mut participants: Vec = receipts.iter().map(|r| r.user_jid.to_string()).collect(); - participants.dedup(); - assert_eq!( - participants, - vec!["111000011112222@lid"], - "two devices are one member: {receipts:?}" - ); - assert_eq!( - receipts - .iter() - .map(|r| (r.status, r.timestamp.timestamp())) - .collect::>(), - vec![ - (MessageStatus::Delivered, 1_700_000_200), - (MessageStatus::Read, 1_700_000_300), - ], - "each state keeps the instant it happened: {receipts:?}" - ); -} - -#[derive(diesel::QueryableByName, Debug)] -struct JidRow { - #[diesel(sql_type = diesel::sql_types::Text)] - jid: String, -} - -#[derive(diesel::QueryableByName, Debug)] -struct ReceiptKeyRow { - #[diesel(sql_type = diesel::sql_types::Text)] - user_jid: String, - #[diesel(sql_type = diesel::sql_types::Integer)] - receipt_type: i32, -} - -/// The heal migration, replayed over rows the pre-fix writers left behind. -/// Migrations run at open, so the artifacts are seeded afterwards and the -/// statements re-applied — they are idempotent by construction. -#[tokio::test] -async fn migration_folds_device_suffixed_rows() { - let (store, _chat_store) = test_store().await; - - store - .shared() - .run(|conn| { - let seed = [ - // Phantom chat from a read-self, plus the real thread. - "INSERT INTO chats (device_id, jid) VALUES (1, '10203040506070:48@lid')", - "INSERT INTO chats (device_id, jid) VALUES (1, '10203040506070@lid')", - // A device-keyed chat that somehow owns messages is left alone. - "INSERT INTO chats (device_id, jid) VALUES (1, '20304050607080:7@lid')", - "INSERT INTO messages (device_id, chat_jid, msg_id, sender_jid, timestamp_ms, kind) \ - VALUES (1, '20304050607080:7@lid', 'M-1', '', 1, 'text')", - // Contact reachable only under the device key… - "INSERT INTO contacts (device_id, jid, push_name) VALUES (1, '30405060708090:5@lid', 'Bob')", - // …and one whose bare row already exists and must win. - "INSERT INTO contacts (device_id, jid, push_name) VALUES (1, '10203040506070:48@lid', 'stale')", - "INSERT INTO contacts (device_id, jid, push_name) VALUES (1, '10203040506070@lid', 'Alice')", - // Same participant split across phone and Web: Read wins. - "INSERT INTO message_receipts (device_id, chat_jid, msg_id, user_jid, receipt_type, ts_ms) \ - VALUES (1, '120363000000000001@g.us', 'G-1', '111000011112222@lid', 3, 10)", - "INSERT INTO message_receipts (device_id, chat_jid, msg_id, user_jid, receipt_type, ts_ms) \ - VALUES (1, '120363000000000001@g.us', 'G-1', '111000011112222:48@lid', 4, 20)", - // Two device rows and no bare row: the highest still survives. - "INSERT INTO message_receipts (device_id, chat_jid, msg_id, user_jid, receipt_type, ts_ms) \ - VALUES (1, '120363000000000001@g.us', 'G-1', '222000011112222:3@lid', 4, 30)", - "INSERT INTO message_receipts (device_id, chat_jid, msg_id, user_jid, receipt_type, ts_ms) \ - VALUES (1, '120363000000000001@g.us', 'G-1', '222000011112222:9@lid', 3, 40)", - ]; - for stmt in seed { - diesel::sql_query(stmt) - .execute(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - } - // Run the file the way the migration harness does, statements and - // comments included. - diesel::connection::SimpleConnection::batch_execute( - conn, - include_str!("../migrations/2026-07-24-000000_bare_identity_keys/up.sql"), - ) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - Ok(()) - }) - .await - .unwrap(); - - let (chats, contacts, receipts) = store - .shared() - .run(|conn| { - let chats: Vec = - diesel::sql_query("SELECT jid FROM chats WHERE device_id = 1 ORDER BY jid") - .load(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - let contacts: Vec = diesel::sql_query( - "SELECT jid || '=' || push_name AS jid FROM contacts WHERE device_id = 1 ORDER BY jid", - ) - .load(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - let receipts: Vec = diesel::sql_query( - "SELECT user_jid, receipt_type FROM message_receipts WHERE device_id = 1 ORDER BY user_jid", - ) - .load(conn) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e)))?; - Ok((chats, contacts, receipts)) - }) - .await - .unwrap(); - - assert_eq!( - chats.iter().map(|r| r.jid.as_str()).collect::>(), - ["10203040506070@lid", "20304050607080:7@lid"] - ); - assert_eq!( - contacts.iter().map(|r| r.jid.as_str()).collect::>(), - ["10203040506070@lid=Alice", "30405060708090@lid=Bob"] - ); - assert_eq!( - receipts - .iter() - .map(|r| (r.user_jid.as_str(), r.receipt_type)) - .collect::>(), - [("111000011112222@lid", 4), ("222000011112222@lid", 4)] - ); -} - -// --------------------------------------------------------------------------- -// Arrival ordering (#1134) -// --------------------------------------------------------------------------- - -/// The server's `t` is whole seconds, so a live back-and-forth lands several -/// messages on the same `timestamp_ms`. The tiebreak used to be `msg_id`, which -/// encodes nothing about time and is biased: this library stamps a constant -/// `3EB0` prefix on the ids it generates, while a peer's ids are effectively -/// uniform hex, so descending id order put the peer's message on top for ~75% -/// of ties — a reply rendering above the message it answers, every time. -#[tokio::test] -async fn same_second_messages_order_by_arrival_not_id() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let second = 1_785_101_675; - - // Inbound first. Its id sorts ABOVE an outgoing `3EB0…` id, which is - // exactly the case that used to inverted the pair. - feed( - &chat_store, - [message_event( - wa::Message::text("1st"), - incoming_info(PEER, PEER, "AAF59A7CB022679C9C44060A10C25026", second), - )], - ) - .await; - chat_store - .record_outgoing( - &chat, - "3EB025E0465016A858A333", - &wa::Message::text("2nd"), - Utc.timestamp_opt(second, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let messages = chat_store.messages(&chat, None, 10).await.unwrap(); - assert_eq!( - messages - .iter() - .map(|m| m.text.as_deref().unwrap()) - .collect::>(), - ["2nd", "1st"], - "the reply is the newer message and must render below nothing" - ); - assert!( - messages[0].seq > messages[1].seq, - "the arrival counter is what breaks the tie" - ); - - // The same tie decides the chat-list preview. - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_preview.as_deref(), Some("2nd")); -} - -/// A page boundary landing inside a same-second run must neither skip nor -/// repeat: the keyset filter has to mirror the sort's tiebreak exactly. -#[tokio::test] -async fn pagination_is_exact_across_a_same_second_run() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - // Six messages sharing one second, ids deliberately out of arrival order. - let ids = ["F1", "A2", "3EB003", "B4", "3EB005", "C6"]; - let events: Vec = ids - .iter() - .enumerate() - .map(|(i, id)| { - message_event( - wa::Message::text(format!("m{i}")), - incoming_info(PEER, PEER, id, 1_700_000_000), - ) - }) - .collect(); - feed(&chat_store, events).await; - - let mut seen = Vec::new(); - let mut cursor = None; - loop { - let page = chat_store.messages(&chat, cursor.take(), 2).await.unwrap(); - if page.is_empty() { - break; - } - cursor = page.last().map(Into::into); - seen.extend(page.into_iter().map(|m| m.text.unwrap())); - } - assert_eq!(seen, ["m5", "m4", "m3", "m2", "m1", "m0"]); -} - -// --------------------------------------------------------------------------- -// Chat list: point lookup and keyset paging (#1141) -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn chat_point_lookup_resolves_either_identity() { - let (store, chat_store) = test_store().await; - add_lid_mapping(&store).await; - - feed( - &chat_store, - [message_event( - wa::Message::text("olá"), - incoming_info(PEER, PEER, "MSG-PT-1", 1_700_000_000), - )], - ) - .await; - - let by_pn = chat_store.chat(&jid(PEER)).await.unwrap().expect("by pn"); - assert_eq!(by_pn.last_message_preview.as_deref(), Some("olá")); - assert_eq!(by_pn.unread_count, 1); - - // The peer's other identity addresses the same thread. - let by_lid = chat_store - .chat(&jid(PEER_LID)) - .await - .unwrap() - .expect("by lid"); - assert_eq!(by_lid.jid, by_pn.jid); - - assert!( - chat_store - .chat(&jid("559900009999@s.whatsapp.net")) - .await - .unwrap() - .is_none() - ); -} - -#[tokio::test] -async fn chat_list_pages_across_the_pinned_boundary() { - let (_store, chat_store) = test_store().await; - - // Four chats, newest last, with the two oldest pinned so they lead. - let peers: Vec = (1..=4) - .map(|i| format!("55990000000{i}@s.whatsapp.net")) - .collect(); - let mut events = Vec::new(); - for (i, peer) in peers.iter().enumerate() { - events.push(message_event( - wa::Message::text(format!("m{i}")), - incoming_info(peer, peer, &format!("MSG-PG-{i}"), 1_700_000_000 + i as i64), - )); - } - // Pin peer 0 then peer 1, so peer 1 (pinned later) sorts first. - for (i, peer) in peers.iter().take(2).enumerate() { - events.push(Event::PinUpdate( - wacore::types::events::PinUpdate::builder() - .jid(jid(peer)) - .timestamp(Utc.timestamp_opt(1_700_000_500 + i as i64, 0).unwrap()) - .action(Box::new(wa::sync_action_value::PinAction { - pinned: Some(true), - })) - .from_full_sync(false) - .build(), - )); - } - feed(&chat_store, events).await; - - let whole = chat_store.chats(false, 10).await.unwrap(); - let expected: Vec = whole.iter().map(|c| c.jid.to_string()).collect(); - assert_eq!( - expected, - vec![ - peers[1].clone(), // pinned last -> first - peers[0].clone(), - peers[3].clone(), // then by activity, newest first - peers[2].clone(), - ] - ); - - // Paging one at a time reproduces that order exactly, crossing from the - // pinned run into the activity run without skipping or repeating. - let mut seen: Vec = Vec::new(); - let mut cursor = None; - loop { - let page = chat_store - .chats_page(false, cursor.take(), 1) - .await - .unwrap(); - if page.is_empty() { - break; - } - cursor = page.last().map(Into::into); - seen.extend(page.iter().map(|c| c.jid.to_string())); - } - assert_eq!(seen, expected); -} - -// --------------------------------------------------------------------------- -// Server ack racing its own outgoing row (#1142) -// --------------------------------------------------------------------------- - -/// `Event::ServerAck` is dispatched on the socket-read path while -/// `send_message` returns at the stanza write, so a host that records its -/// outgoing message after the send resolves can see the ack first. That ack -/// used to be dropped silently, leaving the row on a `pending` clock forever -/// and never applying the server's authoritative timestamp. -#[tokio::test] -async fn server_ack_arriving_before_its_outgoing_row_is_applied_on_insert() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let server_timestamp = Utc.timestamp_opt(1_700_000_222, 0).unwrap(); - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-RACE".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .timestamp(server_timestamp) - .build(), - )], - ) - .await; - // Nothing to apply it to yet. - assert!( - chat_store - .message(&chat, "OUT-RACE") - .await - .unwrap() - .is_none() - ); - - chat_store - .record_outgoing( - &chat, - "OUT-RACE", - &wa::Message::text("beat me to it"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let msg = chat_store - .message(&chat, "OUT-RACE") - .await - .unwrap() - .expect("row"); - assert_eq!(msg.status, MessageStatus::ServerAck); - assert_eq!( - msg.timestamp, server_timestamp, - "the held ack also carries the server's send clock" - ); -} - -/// A nack that beats its row must land as ERROR, not as a silent pending row. -#[tokio::test] -async fn deferred_nack_marks_the_row_as_error() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-NACK-RACE".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .error("473".to_string()) - .build(), - )], - ) - .await; - chat_store - .record_outgoing( - &chat, - "OUT-NACK-RACE", - &wa::Message::text("refused"), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let msg = chat_store - .message(&chat, "OUT-NACK-RACE") - .await - .unwrap() - .expect("row"); - assert_eq!(msg.status, MessageStatus::Error); -} - -/// A held ack belongs to one id only; an unrelated send must not consume it. -#[tokio::test] -async fn deferred_ack_only_matches_its_own_message() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-WAITING".to_string()) - .class("message".to_string()) - .from(chat.clone()) - .build(), - )], - ) - .await; - for id in ["OUT-OTHER", "OUT-WAITING"] { - chat_store - .record_outgoing( - &chat, - id, - &wa::Message::text(id), - Utc.timestamp_opt(1_700_000_100, 0).unwrap(), - ) - .unwrap(); - } - chat_store.flush().await.unwrap(); - - assert_eq!( - chat_store - .message(&chat, "OUT-OTHER") - .await - .unwrap() - .unwrap() - .status, - MessageStatus::Pending - ); - assert_eq!( - chat_store - .message(&chat, "OUT-WAITING") - .await - .unwrap() - .unwrap() - .status, - MessageStatus::ServerAck - ); -} - -/// An ack whose id matches several outgoing rows cannot be attributed to any -/// of them, and waiting cannot disambiguate it. It must be dropped outright — -/// deferring it would arm it for whichever row next claims that id, turning a -/// deliberate refusal into a delayed mis-apply. -#[tokio::test] -async fn ambiguous_ack_is_dropped_rather_than_deferred() { - let (_store, chat_store) = test_store().await; - let other = jid("559900000002@s.whatsapp.net"); - let third = jid("559900000003@s.whatsapp.net"); - let sent_at = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - - // The same id under two chats: no ack can name one of them. - for chat in [&jid(PEER), &other] { - chat_store - .record_outgoing(chat, "OUT-DUP", &wa::Message::text("dup"), sent_at) - .unwrap(); - } - chat_store.flush().await.unwrap(); - - // An ack with no chat identity, so resolution falls to the id alone. - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-DUP".to_string()) - .class("message".to_string()) - .build(), - )], - ) - .await; - for chat in [&jid(PEER), &other] { - assert_eq!( - chat_store - .message(chat, "OUT-DUP") - .await - .unwrap() - .unwrap() - .status, - MessageStatus::Pending, - "an unattributable ack lifts nothing" - ); - } - - // And it is not waiting in the wings: a later row reusing the id stays - // pending too. - chat_store - .record_outgoing(&third, "OUT-DUP", &wa::Message::text("dup"), sent_at) - .unwrap(); - chat_store.flush().await.unwrap(); - assert_eq!( - chat_store - .message(&third, "OUT-DUP") - .await - .unwrap() - .unwrap() - .status, - MessageStatus::Pending - ); -} - -/// An ack that names its chat must stay inside it. Ids are sender-chosen and -/// unique only within a chat, so a same-id row in an unrelated thread is a -/// different message — resolving to it would acknowledge the wrong send and -/// leave the real one pending. -#[tokio::test] -async fn a_named_ack_does_not_resolve_to_another_chats_row() { - let (_store, chat_store) = test_store().await; - let named = jid(PEER); - let other = jid("559900000002@s.whatsapp.net"); - let sent_at = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); - - // Only the OTHER chat has a row under this id. - chat_store - .record_outgoing(&other, "OUT-CROSS", &wa::Message::text("theirs"), sent_at) - .unwrap(); - chat_store.flush().await.unwrap(); - - feed( - &chat_store, - [Event::ServerAck( - ServerAck::builder() - .id("OUT-CROSS".to_string()) - .class("message".to_string()) - .from(named.clone()) - .timestamp(Utc.timestamp_opt(1_700_000_222, 0).unwrap()) - .build(), - )], - ) - .await; - let untouched = chat_store - .message(&other, "OUT-CROSS") - .await - .unwrap() - .unwrap(); - assert_eq!( - untouched.status, - MessageStatus::Pending, - "an ack for another chat must not lift this row" - ); - assert_eq!( - untouched.timestamp, sent_at, - "nor rewrite its clock to that ack's server time" - ); - - // It was held for the chat it named, so that chat's send still gets it. - chat_store - .record_outgoing(&named, "OUT-CROSS", &wa::Message::text("mine"), sent_at) - .unwrap(); - chat_store.flush().await.unwrap(); - assert_eq!( - chat_store - .message(&named, "OUT-CROSS") - .await - .unwrap() - .unwrap() - .status, - MessageStatus::ServerAck - ); -} - -// --------------------------------------------------------------------------- -// Hook-committed batches (#1140) -// --------------------------------------------------------------------------- - -fn hook_committed_event(id: &str) -> Event { - Event::Messages( - MessageBatch::builder() - .messages(Arc::from([InboundMessage::builder() - .message(Arc::new(wa::Message::text("already durable"))) - .info(Arc::new(incoming_info(PEER, PEER, id, 1_700_000_000))) - .build()])) - .origin(BatchOrigin::Live) - .hook_committed(true) - .build(), - ) -} - -/// Once the host declares that its hook feeds this store, a batch the hook -/// committed must not be applied a second time — the duplicate pass is a full -/// proto UPDATE plus an FTS delete+insert plus a doubled invalidation fan-out, -/// not a cheap no-op. -#[tokio::test] -async fn hook_committed_batch_is_skipped_when_opted_in() { - let (_store, chat_store) = test_store().await; - chat_store.skip_hook_committed_batches(true); - - feed(&chat_store, [hook_committed_event("MSG-HOOKED")]).await; - - assert!(chat_store.chats(false, 10).await.unwrap().is_empty()); - assert!( - chat_store - .message(&jid(PEER), "MSG-HOOKED") - .await - .unwrap() - .is_none() - ); -} - -/// The marker alone means "some hook committed this", not "this store already -/// has it". A host whose hook persists elsewhere still needs every batch, so -/// the default must materialize it — skipping would lose acknowledged -/// messages out of history, previews and subscriptions. -#[tokio::test] -async fn hook_committed_batch_is_materialized_by_default() { - let (_store, chat_store) = test_store().await; - - feed(&chat_store, [hook_committed_event("MSG-OTHER-HOOK")]).await; - - assert_eq!( - chat_store - .message(&jid(PEER), "MSG-OTHER-HOOK") - .await - .unwrap() - .expect("a hook that writes elsewhere leaves this store the only materializer") - .text - .as_deref(), - Some("already durable") - ); -} - -/// The producers that bypass the commit pipeline (newsletters, PDO recovery) -/// leave the marker unset, and this handler stays their only materializer. -#[tokio::test] -async fn unmarked_batch_is_still_materialized() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("only copy"), - incoming_info(PEER, PEER, "MSG-UNHOOKED", 1_700_000_000), - )], - ) - .await; - - assert_eq!( - chat_store - .message(&jid(PEER), "MSG-UNHOOKED") - .await - .unwrap() - .expect("row") - .text - .as_deref(), - Some("only copy") - ); -} - -// --------------------------------------------------------------------------- -// Scoped and bounded full-text search (#1147) -// --------------------------------------------------------------------------- - -#[cfg(feature = "search")] -#[tokio::test] -async fn search_can_be_scoped_to_one_chat() { - let (_store, chat_store) = test_store().await; - let other = "559900000002@s.whatsapp.net"; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("orçamento aprovado"), - incoming_info(PEER, PEER, "MSG-SC1", 1_700_000_000), - ), - message_event( - wa::Message::text("orçamento recusado"), - incoming_info(other, other, "MSG-SC2", 1_700_000_001), - ), - ], - ) - .await; - - // Unscoped sees both threads. - assert_eq!( - chat_store - .search_messages("orçamento", 10) - .await - .unwrap() - .len(), - 2 - ); - - // Scoped sees only its own, without the caller over-fetching and filtering. - let scoped = chat_store - .search_messages_in_chat(&jid(PEER), "orçamento", 10) - .await - .unwrap(); - assert_eq!( - scoped.iter().map(|m| m.id.as_str()).collect::>(), - ["MSG-SC1"] - ); -} - -/// A chat addressed by either of the peer's identities is the same thread, so -/// the scope has to resolve through the alias like every other read. -#[cfg(feature = "search")] -#[tokio::test] -async fn scoped_search_resolves_the_peer_alias() { - let (store, chat_store) = test_store().await; - add_lid_mapping(&store).await; - - feed( - &chat_store, - [message_event( - wa::Message::text("combinado então"), - incoming_info(PEER, PEER, "MSG-SC-LID", 1_700_000_000), - )], - ) - .await; - - let by_lid = chat_store - .search_messages_in_chat(&jid(PEER_LID), "combinado", 10) - .await - .unwrap(); - assert_eq!( - by_lid.iter().map(|m| m.id.as_str()).collect::>(), - ["MSG-SC-LID"] - ); -} - -/// Hits come back fully hydrated from one statement rather than a point query -/// each, so everything a caller reads off a hit still has to be there. -#[cfg(feature = "search")] -#[tokio::test] -async fn search_hits_are_fully_hydrated() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("documento anexado"), - incoming_info(PEER, PEER, "MSG-HY", 1_700_000_000), - )], - ) - .await; - - let hits = chat_store.search_messages("documento", 10).await.unwrap(); - assert_eq!(hits.len(), 1); - let hit = &hits[0]; - assert_eq!(hit.id, "MSG-HY"); - assert_eq!(hit.chat_jid, jid(PEER)); - assert_eq!(hit.sender_jid, jid(PEER)); - assert_eq!(hit.text.as_deref(), Some("documento anexado")); - assert_eq!(hit.kind, MessageKind::Text); - assert!(!hit.from_me); - assert!(hit.seq > 0, "arrival sequence survives the bulk load"); - // The proto still decodes — hydration did not drop the blob. - assert_eq!( - hit.message - .as_ref() - .expect("decoded proto") - .conversation - .as_deref(), - Some("documento anexado") - ); -} - -/// A one- or two-character prefix skips relevance ranking, which would -/// otherwise have to score every row it matches before `LIMIT` discarded any. -/// It still has to return that many hits, newest first. -#[cfg(feature = "search")] -#[tokio::test] -async fn a_short_prefix_returns_newest_first_within_its_limit() { - let (_store, chat_store) = test_store().await; - - let events: Vec = (0..6) - .map(|i| { - message_event( - wa::Message::text(format!("hoje{i} agora")), - incoming_info(PEER, PEER, &format!("MSG-SP-{i}"), 1_700_000_000 + i), - ) - }) - .collect(); - feed(&chat_store, events).await; - - let hits = chat_store.search_messages("h", 3).await.unwrap(); - assert_eq!( - hits.iter().map(|m| m.id.as_str()).collect::>(), - ["MSG-SP-5", "MSG-SP-4", "MSG-SP-3"], - "newest first, capped at the limit" - ); - - // One short token demotes the WHOLE query to recency — the rule is `all`, - // not `any`, so a mixed-length search must not quietly go back to ranking. - let mixed = chat_store.search_messages("hoje a", 3).await.unwrap(); - assert_eq!( - mixed.iter().map(|m| m.id.as_str()).collect::>(), - ["MSG-SP-5", "MSG-SP-4", "MSG-SP-3"] - ); - - // A long-enough term still ranks, and still finds its message. - let ranked = chat_store.search_messages("hoje4", 10).await.unwrap(); - assert_eq!( - ranked.iter().map(|m| m.id.as_str()).collect::>(), - ["MSG-SP-4"] - ); -} - -#[cfg(feature = "search")] -#[tokio::test] -async fn scoped_search_rejects_an_empty_query_like_the_unscoped_one() { - let (_store, chat_store) = test_store().await; - assert!( - chat_store - .search_messages_in_chat(&jid(PEER), " ", 10) - .await - .is_err() - ); - assert_eq!( - chat_store - .search_messages_in_chat(&jid(PEER), "olá", 0) - .await - .unwrap() - .len(), - 0 - ); -} - -// --------------------------------------------------------------------------- -// Unavailable fanouts keep their type (#1150) -// --------------------------------------------------------------------------- - -fn unavailable_event( - id: &str, - ts: i64, - unavailable_type: wacore::types::events::UnavailableType, -) -> Event { - Event::UndecryptableMessage( - wacore::types::events::UndecryptableMessage::builder() - .info(Arc::new(incoming_info(PEER, PEER, id, ts))) - .is_unavailable(true) - .unavailable_type(unavailable_type) - .decrypt_fail_mode(wacore::types::events::DecryptFailMode::Show) - .build(), - ) -} - -/// The three unrecoverable fanouts are content the phone never shares with a -/// companion, so their rows are permanent by design. Flattening them into the -/// generic placeholder left a frontend rendering "waiting for this message" -/// for something that will never arrive. -#[tokio::test] -async fn unrecoverable_fanouts_keep_their_type() { - use wacore::types::events::UnavailableType; - - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - let cases = [ - (UnavailableType::ViewOnce, MessageKind::ViewOnce, "MSG-VO"), - (UnavailableType::Hosted, MessageKind::Hosted, "MSG-HO"), - (UnavailableType::Bot, MessageKind::Bot, "MSG-BO"), - ]; - - for (at, (unavailable_type, _, id)) in cases.iter().enumerate() { - feed( - &chat_store, - [unavailable_event( - id, - 1_700_000_000 + at as i64, - *unavailable_type, - )], - ) - .await; - } - - for (unavailable_type, expected, id) in cases { - let row = chat_store - .message(&chat, id) - .await - .unwrap() - .unwrap_or_else(|| panic!("{unavailable_type:?} should materialize")); - assert_eq!(row.kind, expected, "{unavailable_type:?}"); - assert!(row.message.is_none(), "{unavailable_type:?}: no content"); - } - - // The chat preview carries it too, so a chat list can render the chip - // without opening the thread. - let chats = chat_store.chats(false, 10).await.unwrap(); - assert_eq!(chats[0].last_message_kind, Some(MessageKind::Bot)); -} - -/// A plain fanout is still recoverable — PDO may fill it in — so it must keep -/// the placeholder kind and the "yet" it implies. -#[tokio::test] -async fn a_recoverable_fanout_stays_undecryptable() { - use wacore::types::events::UnavailableType; - - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - feed( - &chat_store, - [unavailable_event( - "MSG-PLAIN", - 1_700_000_000, - UnavailableType::Unknown, - )], - ) - .await; - - assert_eq!( - chat_store - .message(&chat, "MSG-PLAIN") - .await - .unwrap() - .unwrap() - .kind, - MessageKind::Undecryptable - ); -} - -/// The labels are on-disk values, so a reader on an older build still round -/// trips them rather than losing the row. -#[test] -fn unavailable_kind_labels_are_stable() { - for (kind, label) in [ - (MessageKind::ViewOnce, "view_once"), - (MessageKind::Hosted, "hosted"), - (MessageKind::Bot, "bot"), - (MessageKind::Undecryptable, "undecryptable"), - ] { - assert_eq!(kind.as_str(), label); - } -} - -// --- Session-wide arrival feed --------------------------------------------- - -/// One page spans every chat, in the order the rows landed — the read an -/// external reconciler needs and could otherwise only fake by paging each -/// thread. -#[tokio::test] -async fn arrival_feed_interleaves_every_chat_newest_first() { - let (_store, chat_store) = test_store().await; - let other = "559900000002@s.whatsapp.net"; - - feed( - &chat_store, - [ - message_event( - wa::Message::text("peer one"), - incoming_info(PEER, PEER, "A-1", 1_700_000_000), - ), - message_event( - wa::Message::text("group"), - incoming_info(GROUP, PEER, "G-1", 1_700_000_001), - ), - message_event( - wa::Message::text("peer two"), - incoming_info(other, other, "B-1", 1_700_000_002), - ), - ], - ) - .await; - - let page = chat_store.messages_by_arrival(None, 10).await.unwrap(); - assert_eq!( - page.iter().map(|m| m.id.as_str()).collect::>(), - ["B-1", "G-1", "A-1"] - ); - // Every chat is represented, and `seq` really is descending. - assert_eq!(page[0].chat_jid, jid(other)); - assert_eq!(page[1].chat_jid, jid(GROUP)); - assert!(page[0].seq > page[1].seq && page[1].seq > page[2].seq); -} - -/// Pages tile the feed: walking the cursor to exhaustion yields every row -/// exactly once, which is the whole contract a resumable consumer relies on. -#[tokio::test] -async fn arrival_feed_pages_without_gaps_or_repeats() { - let (_store, chat_store) = test_store().await; - - let events: Vec<_> = (0..7) - .map(|i| { - let chat = if i % 2 == 0 { PEER } else { GROUP }; - message_event( - wa::Message::text("m"), - incoming_info(chat, PEER, &format!("M-{i}"), 1_700_000_000 + i), - ) - }) - .collect(); - feed(&chat_store, events).await; - - let mut seen = Vec::new(); - let mut cursor = None; - loop { - let page = chat_store.messages_by_arrival(cursor, 3).await.unwrap(); - let Some(last) = page.last() else { break }; - cursor = Some(last.into()); - seen.extend(page.iter().map(|m| m.id.clone())); - } - - assert_eq!( - seen, - (0..7).rev().map(|i| format!("M-{i}")).collect::>() - ); -} - -/// The property that rules out a `timestamp_ms` cursor: history sync backfills -/// old conversations at NEW arrival positions. A timestamp-keyed poller would -/// file those rows behind its watermark and never look at them again; the -/// arrival feed puts them at the head, where the next pull sees them. -#[tokio::test] -async fn arrival_feed_surfaces_backfill_dated_before_the_last_page() { - let (_store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("live"), - incoming_info(PEER, PEER, "LIVE-1", 1_700_000_000), - )], - ) - .await; - let watermark: whatsapp_rust_chat_store::ArrivalCursor = - (&chat_store.messages_by_arrival(None, 10).await.unwrap()[0]).into(); - - // Two years older than the live message, and it arrives now. - let old_ts = 1_640_000_000u64; - let history = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::RECENT, - conversations: vec![wa::Conversation { - id: GROUP.to_string(), - messages: vec![wa::HistorySyncMsg { - message: MessageField::some(wa::WebMessageInfo { - key: MessageField::some(wa::MessageKey { - remote_jid: Some(GROUP.into()), - from_me: Some(false), - id: Some("BACKFILL-1".into()), - participant: Some(PEER.into()), - }), - message: MessageField::from_box(Box::new(wa::Message::text("old"))), - message_timestamp: Some(old_ts), - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }], - ..Default::default() - }; - feed(&chat_store, [history_sync_event(history)]).await; - - let head = chat_store.messages_by_arrival(None, 10).await.unwrap(); - assert_eq!(head[0].id, "BACKFILL-1", "backfill sits at the newest end"); - assert!( - head[0].timestamp < head[1].timestamp, - "and it is genuinely older than the row it outranks" - ); - assert!( - head[0].seq > watermark.seq, - "so a stale cursor still sees it" - ); -} - -/// The wall-clock window is half-open, `since <= timestamp < until`, so a -/// consumer walking adjacent windows neither double-counts nor drops a row. -#[tokio::test] -async fn arrival_feed_window_is_half_open() { - let (_store, chat_store) = test_store().await; - - let events: Vec<_> = (0..4) - .map(|i| { - message_event( - wa::Message::text("m"), - incoming_info(PEER, PEER, &format!("W-{i}"), 1_700_000_000 + i), - ) - }) - .collect(); - feed(&chat_store, events).await; - - let at = |secs: i64| Utc.timestamp_opt(secs, 0).unwrap(); - let ids = |page: Vec| { - page.into_iter().map(|m| m.id).collect::>() - }; - - let window = chat_store - .messages_by_arrival_in_range(None, Some(at(1_700_000_001)), Some(at(1_700_000_003)), 10) - .await - .unwrap(); - assert_eq!(ids(window), ["W-2", "W-1"]); - - // Bounds are independent. - let open_end = chat_store - .messages_by_arrival_in_range(None, Some(at(1_700_000_002)), None, 10) - .await - .unwrap(); - assert_eq!(ids(open_end), ["W-3", "W-2"]); - let open_start = chat_store - .messages_by_arrival_in_range(None, None, Some(at(1_700_000_001)), 10) - .await - .unwrap(); - assert_eq!(ids(open_start), ["W-0"]); - - // The cursor still bounds the window rather than being overridden by it. - let resumed = chat_store - .messages_by_arrival_in_range( - Some((&chat_store.messages_by_arrival(None, 10).await.unwrap()[0]).into()), - Some(at(1_700_000_001)), - None, - 10, - ) - .await - .unwrap(); - assert_eq!(ids(resumed), ["W-2", "W-1"]); -} - -/// Tombstones are rows too, and a revoke does NOT reorder them. Both halves -/// matter and they pull in opposite directions: the feed carries the tombstone, -/// so a full pass sees the withdrawal, but the revoke rewrites the row in place -/// and leaves `seq` where the insert put it — so a consumer tailing only the -/// head walks straight past a message that was revoked after it read it. That -/// is the boundary between this feed and `subscribe()`, and it is documented on -/// `messages_by_arrival_in_range` because it is not guessable from the name. -#[tokio::test] -async fn arrival_feed_carries_tombstones_without_reordering_them() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - chat_store - .record_outgoing( - &chat, - "REVOKED-1", - &wa::Message::text("oops"), - Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - let before = chat_store.messages_by_arrival(None, 10).await.unwrap(); - let seq_before = before[0].seq; - - // A later message, so "did the revoke move it to the head?" has an answer. - feed( - &chat_store, - [message_event( - wa::Message::text("after"), - incoming_info(PEER, PEER, "LATER-1", 1_700_000_005), - )], - ) - .await; - chat_store - .record_revoke( - &chat, - "REVOKED-1", - Utc.timestamp_opt(1_700_000_010, 0).unwrap(), - ) - .unwrap(); - chat_store.flush().await.unwrap(); - - let page = chat_store.messages_by_arrival(None, 10).await.unwrap(); - assert_eq!( - page.iter().map(|m| m.id.as_str()).collect::>(), - ["LATER-1", "REVOKED-1"], - "the revoke must not move the row it tombstoned" - ); - let tombstone = &page[1]; - assert!(tombstone.revoked); - assert!(tombstone.message.is_none()); - assert_eq!(tombstone.seq, seq_before, "arrival position is immutable"); -} - -/// Another device's history in the same file is not this session's feed. The -/// `device_id` predicate is the only thing scoping a read that has no chat key -/// to narrow it, so it gets its own test. -#[tokio::test] -async fn arrival_feed_is_scoped_to_the_session_device() { - let (store, chat_store) = test_store().await; - - feed( - &chat_store, - [message_event( - wa::Message::text("mine"), - incoming_info(PEER, PEER, "MINE-1", 1_700_000_000), - )], - ) - .await; - - let sibling = store.device_id() + 1; - store - .shared() - .run(move |conn| { - diesel::sql_query(format!( - "INSERT INTO messages (device_id, chat_jid, msg_id, sender_jid, from_me, \ - timestamp_ms, kind, text_content, status, starred, revoked) \ - VALUES ({sibling}, '{PEER}', 'THEIRS-1', '{PEER}', 0, 1700000001000, \ - 'text', 'theirs', 2, 0, 0)" - )) - .execute(conn) - .map(|_| ()) - .map_err(|e| wacore::store::error::StoreError::Database(Box::new(e))) - }) - .await - .unwrap(); - - let page = chat_store.messages_by_arrival(None, 10).await.unwrap(); - assert_eq!( - page.iter().map(|m| m.id.as_str()).collect::>(), - ["MINE-1"], - "the sibling device's row has the newer seq and must still be absent" - ); -} - -/// A limit of zero (or a negative one, which SQLite reads as unbounded) returns -/// nothing rather than the whole table. -#[tokio::test] -async fn arrival_feed_rejects_an_unbounded_limit() { - let (_store, chat_store) = test_store().await; - feed( - &chat_store, - [message_event( - wa::Message::text("m"), - incoming_info(PEER, PEER, "L-1", 1_700_000_000), - )], - ) - .await; - - assert!( - chat_store - .messages_by_arrival(None, 0) - .await - .unwrap() - .is_empty() - ); - assert!( - chat_store - .messages_by_arrival(None, -1) - .await - .unwrap() - .is_empty() - ); -} - -/// Sub-millisecond bounds are honored exactly. `Utc::now()` carries -/// nanoseconds, so a caller asking for "the last hour" hits this on every call; -/// truncating the bound gets both ends of the half-open window backwards. -#[tokio::test] -async fn arrival_feed_window_honors_sub_millisecond_bounds() { - let (_store, chat_store) = test_store().await; - feed( - &chat_store, - [message_event( - wa::Message::text("m"), - incoming_info(PEER, PEER, "SUB-1", 1_700_000_000), - )], - ) - .await; - // The stored row sits exactly on a whole second, so on a whole millisecond. - let on_the_ms = Utc.timestamp_opt(1_700_000_000, 0).unwrap(); - let just_after = on_the_ms + chrono::Duration::microseconds(500); - - // `since` half a microsecond past the row excludes it: the row precedes - // the bound. Truncation would have kept it. - assert!( - chat_store - .messages_by_arrival_in_range(None, Some(just_after), None, 10) - .await - .unwrap() - .is_empty() - ); - // ...and `until` at the same instant includes it, for the same reason. - assert_eq!( - chat_store - .messages_by_arrival_in_range(None, None, Some(just_after), 10) - .await - .unwrap() - .len(), - 1 - ); - // A bound exactly on the row keeps the half-open contract: `since` - // includes, `until` excludes. - assert_eq!( - chat_store - .messages_by_arrival_in_range(None, Some(on_the_ms), None, 10) - .await - .unwrap() - .len(), - 1 - ); - assert!( - chat_store - .messages_by_arrival_in_range(None, None, Some(on_the_ms), 10) - .await - .unwrap() - .is_empty() - ); -} - -/// The fact that rules out a remembered `seq`: SQLite assigns the implicit -/// rowid as `max(rowid) + 1`, so deleting the newest message hands its number -/// to the next arrival. A consumer that stopped at a saved watermark would read -/// that brand-new message as already seen and drop it — silently, and after an -/// ordinary delete-for-me, not an exotic one. -#[tokio::test] -async fn a_new_message_can_land_at_a_previously_used_seq() { - let (_store, chat_store) = test_store().await; - let chat = jid(PEER); - - feed( - &chat_store, - [ - message_event( - wa::Message::text("first"), - incoming_info(PEER, PEER, "REUSE-1", 1_700_000_000), - ), - message_event( - wa::Message::text("newest"), - incoming_info(PEER, PEER, "REUSE-2", 1_700_000_001), - ), - ], - ) - .await; - let watermark = chat_store.messages_by_arrival(None, 10).await.unwrap()[0].seq; - - feed( - &chat_store, - [Event::DeleteMessageForMeUpdate( - wacore::types::events::DeleteMessageForMeUpdate::builder() - .chat_jid(chat.clone()) - .message_id("REUSE-2".to_string()) - .from_me(false) - .timestamp(Utc.timestamp_opt(1_700_000_002, 0).unwrap()) - .action(Box::new( - wa::sync_action_value::DeleteMessageForMeAction::default(), - )) - .from_full_sync(false) - .build(), - )], - ) - .await; - feed( - &chat_store, - [message_event( - wa::Message::text("genuinely new"), - incoming_info(PEER, PEER, "REUSE-3", 1_700_000_002), - )], - ) - .await; - - let page = chat_store.messages_by_arrival(None, 10).await.unwrap(); - assert_eq!(page[0].id, "REUSE-3"); - assert!( - page[0].seq <= watermark, - "a new arrival reused the deleted row's seq ({} vs watermark {}), which \ - is why the documented loop stops on content and not on a saved seq", - page[0].seq, - watermark - ); -}