Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions api/chat-store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ A 1:1 peer has two interchangeable wire identities — phone number (`@s.whatsap
- **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`](#querying), [`message`](#querying), [`reactions`](#querying) and [`receipts`](#querying) 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.
- **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-state receipts, sticky metadata (pin/mute/archive/name/ephemeral) kept, badge recounted. A state both sides already recorded keeps the earlier of the two timestamps, rather than either side winning arbitrarily. Ties go to the LID side.

To force this healing eagerly instead of waiting for live traffic:

Expand All @@ -159,7 +159,7 @@ Reconcile a 1:1 peer's PN- and LID-keyed rows into a single thread on demand. Id
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()`](#querying).
- **One row per participant per state, not per device.** A group member reading on their phone and again on Web still produces a single `Read` row in [`receipts()`](#querying), not two. Each reported state gets its own row instead of overwriting the previous one.
- **[`contact()`](#querying) resolves either form.** A caller holding a message's `sender` (which keeps its device by design) finds the same [`ContactEntry`](#contactentry) as a caller holding the peer's bare identity — both look up the same row.

This is unrelated to [PN/LID identity aliasing](#pnlid-identity-aliasing) above: it normalizes the device suffix on one identity, not which of the two identities a thread is keyed by.
Expand Down Expand Up @@ -210,7 +210,11 @@ async fn contact(&self, jid: &Jid) -> Result<Option<ContactEntry>>;
async fn unread_total(&self) -> Result<i64>;
```

`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.
`message`, `reactions` and `receipts` accept either of a 1:1 peer's identities the same way `messages` does. `receipts` returns one row per participant per state their receipts have reported, not one row per participant. `Delivered`, `Read`, and `Played` are each recorded as a separate row when reported — a `Read` receipt with no prior `Delivered` receipt produces only a `Read` row, not both. Each row's `timestamp` is the instant that state was first reported. `unread_total` sums only positive unread counters, ignoring the `-1` manually-marked-unread sentinel on individual chats.

<Note>
**Breaking change:** `receipts` used to return rows only for group chats. A 1:1's delivery/read state lived solely in `StoredMessage.status`, with no per-state timestamp available. It now records the same per-state rows for 1:1 chats too, so a caller can render "Delivered hh:mm" above "Read hh:mm" the way WA Web's contact message-info drawer does. A caller that assumed at most one row per participant should now expect up to three: `Delivered`, `Read`, and `Played`. They come back oldest state first.
</Note>

```rust
async fn put_media_ref(
Expand Down Expand Up @@ -393,6 +397,8 @@ pub struct ReceiptEntry {
}
```

A message can carry several `ReceiptEntry` rows for the same `user_jid` — one per state (`Delivered`, `Read`, `Played`) that peer's receipts have reported. A state with no matching receipt has no row; states aren't backfilled just because a later one arrived. Each row's `timestamp` is the earliest instant that state was reported. This applies to both 1:1 and group chats; see [Querying](#querying) and [Companion-device identities](#companion-device-identities).

### ContactEntry

```rust
Expand Down Expand Up @@ -450,12 +456,13 @@ pub type Result<T> = std::result::Result<T, ChatStoreError>;

## 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 status; receipt rows track a minimum timestamp.** `StoredMessage.status` only ever moves forwarda late-arriving `delivered` can't downgrade a `read`. A [`ReceiptEntry`](#reactionentry--receiptentry) row works differently: a state (`Delivered`/`Read`/`Played`) gets at most one row per participant. That row is created the first time the state is reported and is never deleted afterward. Its `timestamp` holds the earliest instant the state was reported so far — a later report of the same state can only lower that timestamp, never raise it, and can never remove the row.
- **Outgoing timestamps converge on the server clock.** A positive message ack that carries a server timestamp replaces the optimistic local timestamp set by [`record_outgoing`](#recording-outgoing-messages), reordering the thread and chat list as needed — see [Outgoing timestamp reconciliation](#outgoing-timestamp-reconciliation).
- **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.
- **A receipt for a message no chat holds is dropped, not parked.** A receipt's message id is chosen by the original sender and echoed back by the peer, not assigned by the server. When that id doesn't match any stored row, the store can't tell an unrecorded send apart from a message the user already deleted — both look identical: an addressed id with nothing behind it. The second reading is the common case, since a peer's receipt costs a round trip and typically arrives well after the send it answers. A receipt is only ever recorded once the message it names is found under the addressed chat key or its PN/LID counterpart.
- **Reaction removal is a tombstone, not a delete.** Removing a reaction (an empty-emoji event, or [`record_reaction`](#recording-local-amendments) with `emoji: ""`) keeps the row so a stale, older reaction arriving later — e.g. from a history chunk — can't resurrect it. [`reactions()`](#querying) hides tombstoned rows.
- **PN/LID splits heal, they don't recur.** Once live traffic (or [`reconcile_chat`](#pnlid-identity-aliasing)) 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](#companion-device-identities).
Expand Down