From bc2ca654e8766297bfa657a5bceec83483630963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Mon, 24 Aug 2026 13:11:56 -0300 Subject: [PATCH] chore: extract the chat store into its own repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat store materializes the event stream into chats, previews, unread counts, ordering and a search index. Those are application decisions, not protocol ones, and this repository implements the protocol. Nothing about the crate needed to live here: it consumed only the public surface — `Event`, `Jid`, `proto_helpers`, `time`, and one `SqliteStore` — and owned its schema and migrations outright. It was never published, so no released API changes. The bundled-SQLite trim keeps FTS5 despite nothing here indexing on it now. This config is not inherited by consumers, so leaving it on is what keeps proving the subsystem compiles for embedders that want it. --- .cargo/config.toml | 10 +- .github/workflows/main.yml | 2 +- Cargo.lock | 20 - Cargo.toml | 1 - storages/chat-store/Cargo.toml | 48 - .../down.sql | 6 - .../up.sql | 96 - .../down.sql | 3 - .../up.sql | 70 - .../down.sql | 2 - .../up.sql | 29 - .../down.sql | 3 - .../up.sql | 30 - .../down.sql | 26 - .../up.sql | 37 - storages/chat-store/src/error.rs | 24 - storages/chat-store/src/fts.rs | 267 - storages/chat-store/src/lib.rs | 46 - storages/chat-store/src/lid.rs | 396 - storages/chat-store/src/materialize.rs | 626 -- storages/chat-store/src/queries.rs | 796 --- storages/chat-store/src/schema.rs | 92 - storages/chat-store/src/store.rs | 3062 -------- storages/chat-store/src/types.rs | 335 - storages/chat-store/tests/chat_store_test.rs | 6338 ----------------- 25 files changed, 7 insertions(+), 12358 deletions(-) delete mode 100644 storages/chat-store/Cargo.toml delete mode 100644 storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/down.sql delete mode 100644 storages/chat-store/migrations/2026-07-09-000000_chat_store_initial/up.sql delete mode 100644 storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/down.sql delete mode 100644 storages/chat-store/migrations/2026-07-24-000000_bare_identity_keys/up.sql delete mode 100644 storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/down.sql delete mode 100644 storages/chat-store/migrations/2026-07-27-000000_message_arrival_order/up.sql delete mode 100644 storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/down.sql delete mode 100644 storages/chat-store/migrations/2026-07-27-000001_chat_list_indexes/up.sql delete mode 100644 storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/down.sql delete mode 100644 storages/chat-store/migrations/2026-07-27-000002_message_receipts_per_state/up.sql delete mode 100644 storages/chat-store/src/error.rs delete mode 100644 storages/chat-store/src/fts.rs delete mode 100644 storages/chat-store/src/lib.rs delete mode 100644 storages/chat-store/src/lid.rs delete mode 100644 storages/chat-store/src/materialize.rs delete mode 100644 storages/chat-store/src/queries.rs delete mode 100644 storages/chat-store/src/schema.rs delete mode 100644 storages/chat-store/src/store.rs delete mode 100644 storages/chat-store/src/types.rs delete mode 100644 storages/chat-store/tests/chat_store_test.rs 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 - ); -}