Skip to main content

Overview

whatsapp-rust-chat-store is an opt-in crate that materializes the client’s event stream into queryable chat/message history tables, so a UI or stateful bot survives a restart without re-syncing from WhatsApp. It has no UI dependencies of its own.
  • Event-sourced. Register chat_store.handler() on the client; a single write-behind writer task applies batched events in one transaction per drained batch, preserving event order. Covers messages, receipts, server acks/nacks, edits, revokes, reactions, history sync, app-state chat updates (pin/mute/archive/read/star/delete/clear), push names, and contact actions.
  • Proto as source of truth. Each message row stores the encoded wa::Message plus denormalized columns (kind, text, status). New proto fields never require a schema migration.
  • Query + invalidation, not a second cache. Reads are async queries (keyset pagination for message pages — stable under concurrent inserts, never OFFSET); consumers subscribe to a StoreChange broadcast and re-query what they display.
  • Shares the device store’s database file. Writes go through SqliteStore::shared(), so the chat tables live in the same file as device/session state without a second connection pool contending for the WAL lock. Backing up the full session stays “copy one file”. Multi-account ready (device_id on every row).
This is a separate, opt-in crate — adding it does not change any behavior for existing whatsapp-rust consumers who don’t depend on it. It has no integration point in the client beyond the existing register_handler mechanism.

Installation

ChatStore::new takes a &whatsapp_rust_sqlite_storage::SqliteStore directly (not the whatsapp_rust::store::SqliteStore re-export), so add that crate alongside whatsapp-rust-chat-store:
Cargo.toml

Setting up the store

ChatStore binds to an existing SqliteStore — it runs its own migrations on first use and shares that store’s connection pool and write semaphore via SqliteStore::shared():
ChatStore::new returns Arc<ChatStore> — the intended usage is one long-lived shared handle, held alongside the client for the process lifetime.

Recording outgoing messages

Incoming events (messages, receipts, acks) are captured automatically once the handler is registered. Messages the client itself sends need to be recorded explicitly, since sends don’t go through the inbound event pipeline:
record_outgoing is synchronous — it only enqueues onto the writer channel and returns, it does not wait for the write to commit. The row starts at MessageStatus::Pending and is lifted by the server ack/receipts that follow. Because the enqueue happens on the same writer queue as inbound events, it cannot race the ack that immediately follows a send. Call flush() if you need to wait for the row to actually land before reading it back. chat may be either of a 1:1 peer’s identities (phone number or LID) — see PN/LID identity aliasing for how the store routes and reads across the two.

Waiting for writes to land

Awaits until every write enqueued before the call has committed. Returns ChatStoreError::WriteBatchFailed if the batch containing one of those writes rolled back. Mainly useful in tests, or right before reading your own just-sent message back out.

PN/LID identity aliasing

A 1:1 peer has two interchangeable wire identities — phone number (@s.whatsapp.net) and LID (@lid) — and inbound traffic for the same thread can arrive addressed under either one, regardless of which key the thread’s rows already live under.
  • An existing thread keeps its key. Whichever identity addresses it, live traffic (messages, receipts, reactions, app-state updates) routes to the thread that already exists, matching WA Web’s selectChatForOneOnOneMessage.
  • A brand-new chat is keyed by LID when the peer already has a known PN↔LID mapping; otherwise it’s keyed by whichever identity first addressed it.
  • Every read resolves the alias. messages, message, reactions and receipts all accept either of the peer’s identities as the chat argument and match rows stored under either key — so a caller that only ever addresses a peer by phone number keeps working even if some rows ended up under the LID key (or vice versa).
  • Splits heal automatically. If a peer’s rows are split across both keys (for example, from receipts that arrived under the wrong identity before this resolution existed), the next piece of live traffic for that peer merges the pair into one thread — advance-only status/star/revoke/edit conflict resolution, union of reactions and per-user receipts, sticky metadata (pin/mute/archive/name/ephemeral) kept, badge recounted. Ties go to the LID side.
To force this healing eagerly instead of waiting for live traffic:
Reconcile a 1:1 peer’s PN- and LID-keyed rows into a single thread on demand. Idempotent — a peer with one thread (or no LID mapping yet) is a no-op. Like record_outgoing, this goes through the writer queue and returns immediately; call flush() to await completion.

Companion-device identities

A peer’s linked device (WhatsApp Web/Desktop) addresses traffic under a device-suffixed JID — 10203040506070:48@lid rather than the peer’s bare 10203040506070@lid. Every row the store keys on identity (chats, contacts, message receipts) uses the bare form, so a companion device’s traffic is normalized rather than filed under a key nothing else reads:
  • Receipts from a companion device count. Multi-device delivery/read semantics are any-device — WhatsApp emits the receipt once, from whichever of the peer’s devices acted first, and never re-sends it from the primary — so messages.status advances the same whether the ack came from the peer’s phone or their linked device.
  • One “read by” row per participant, not per device. A group member reading on their phone and again on Web still produces a single row in receipts().
  • contact() resolves either form. A caller holding a message’s sender (which keeps its device by design) finds the same ContactEntry as a caller holding the peer’s bare identity — both look up the same row.
This is unrelated to PN/LID identity aliasing above: it normalizes the device suffix on one identity, not which of the two identities a thread is keyed by.

Querying

All query methods are async and run on the shared pool’s blocking thread pool.
Chat list ordered pinned-first, then by most recent activity. Not cursor-paginated — pass the page size you need.
Newest-first, keyset-paginated: pass the MessageCursor of the oldest message you already hold (MessageCursor::from(&stored_message)) to fetch the page before it. There is no OFFSET-based paging — keyset pagination stays stable under concurrent inserts (e.g. a new message arriving while a chat history is being scrolled). chat accepts either of a 1:1 peer’s identities (phone number or LID) — see PN/LID identity aliasing.
message, reactions and receipts accept either of a 1:1 peer’s identities the same way messages does. receipts returns per-user delivery/read state — the group “read by” list. unread_total sums only positive unread counters, ignoring the -1 manually-marked-unread sentinel on individual chats.
A content-hash-keyed record of where a downloaded media blob was saved locally — call put_media_ref after downloading media, then use media_ref to check whether a file with the same file_sha256 was already downloaded before fetching it again.

Subscribing to changes

Emits one StoreChange per committed write batch (deduplicated per kind). The channel buffers 256 messages; a Lagged receiver should treat it as “something changed” and re-query whatever it currently displays rather than trying to replay the gap.
StoreChange is a pure invalidation signal — it never carries row data. With the search feature enabled, ChatStore maintains a SQLite FTS5 index over message text and exposes:
Best match first. The query is treated as plain whitespace-separated words — each is turned into a quoted prefix term, so FTS5 operator syntax in user input can’t reach the query parser. An empty or whitespace-only query returns ChatStoreError::InvalidSearchQuery. The FTS5 virtual table and its sync triggers are created lazily and idempotently in ChatStore::new() when the feature is enabled, not via a migration — a build without search leaves no FTS objects behind.
The FTS triggers use a single delete+insert per row change rather than a WHEN-guarded variant — the guarded form looks equivalent but corrupts FTS5 rank queries under certain update patterns. Don’t “simplify” the trigger bodies if you’re forking this crate.

Types

ChatEntry

StoredMessage

MessageCursor

The keyset-pagination cursor used by messages().

MessageKind

Template/Buttons/List/Interactive are WABA business content — a template notification, a message with quick-reply buttons, a list picker, or a flow/native-flow interactive message. Each has a *Reply/*Response counterpart for what a user’s tap materializes as. text is extracted per type mirroring WA Web’s parsers (e.g. a template’s hydrated_content_text, a list’s description, a response’s selected_display_text); footer text and button labels stay in the stored proto rather than text. A non-hydrated template still classifies as Template with text: None. Other(String) round-trips a forward-compatible label for message types added after this crate was released.

MessageStatus

Mirrors WebMessageInfo.Status. Status transitions are enforced monotonic by the writer — a late Delivered receipt can never downgrade a row already at Read, and a server nack only fails a still-Pending row.

ReactionEntry / ReceiptEntry

ContactEntry

.display_name() resolves the name to show, in the same precedence order as WA Web: full_namefirst_namepush_namebusiness_name.
business_name is learned live from inbound messages: a business sender’s <verified_name> cert (decoded into MessageInfo.verified_name) is written to the contact row the same way push_name already is. A message without a cert never clobbers a name learned from an earlier one.

MediaRef

StoreChange

Error handling

WriteBatchFailed carries the underlying error as text rather than the original typed error, since one failed batch fans out to every pending flush() caller and StoreError isn’t Clone.

Semantics worth knowing

  • Monotonic status. Per-message status and per-user group receipts only ever move forward; a late-arriving delivered can’t downgrade a read.
  • Monotonic read state. Self-read state is tracked as a keyed cursor (watermark + boundary message id), so same-second siblings resolve deterministically and a stale replayed read/receipt can’t resurrect an unread badge. A no-op read still clears a manual-unread marker.
  • Offline-drain reordering is handled. If a revoke or edit arrives before the message it targets (common when draining a backlog), the target is materialized as already-revoked/edited up front — the original content’s later arrival can’t resurrect revoked content or show pre-edit text, and never double-counts unread.
  • History sync never clobbers live rows (ON CONFLICT DO NOTHING). Live redeliveries and PDO recovery replace content in place instead — an Undecryptable placeholder becomes the real message under the same id once it’s recovered.
  • Content refreshes are sender-scoped. Message ids are sender-chosen, so a different sender reusing an id can’t rewrite someone else’s message.
  • PN/LID splits heal, they don’t recur. Once live traffic (or reconcile_chat) merges a peer’s phone-number- and LID-keyed rows into one thread, later traffic under either identity keeps routing to that same thread — it can’t re-split.
  • Companion-device traffic never forks a thread. A device-suffixed identity is normalized to the bare peer before it reaches routing, so a linked device can’t materialize a chat, contact, or receipt row of its own — see Companion-device identities.

See also

  • Storage Traits - SqliteStore and the underlying backend traits
  • Custom Backends - Implementing your own storage backend
  • Events - The event system ChatStore’s handler listens to