Skip to main content

Overview

WhatsApp-Rust uses a layered storage architecture with pluggable backends. The PersistenceManager manages all state changes, while the Backend trait defines storage operations for device data, Signal protocol keys, app state sync, and protocol-specific data.

Architecture

PersistenceManager

Location: src/store/persistence_manager.rs

Purpose

Manages all device state changes and persistence operations. Acts as the gatekeeper for state modifications.

Structure

pub struct PersistenceManager {
    device: Arc<RwLock<Device>>,
    backend: Arc<dyn Backend>,
    dirty: Arc<AtomicBool>,
    save_notify: Arc<Event>,  // event_listener::Event (runtime-agnostic)
}
Fields:
  • device: In-memory device state (protected by async_lock::RwLock)
  • backend: Storage backend implementation
  • dirty: Flag indicating unsaved changes
  • save_notify: Notification channel for background saver (uses event_listener::Event for runtime-agnostic operation)

Initialization

impl PersistenceManager {
    pub async fn new(backend: Arc<dyn Backend>) -> Result<Self, StoreError> {
        // Ensure device row exists
        let exists = backend.exists().await?;
        if !exists {
            backend.create().await?;
        }

        // Load existing data or create new
        let device_data = backend.load().await?;
        let device = if let Some(data) = device_data {
            let mut dev = Device::new(backend.clone());
            dev.load_from_serializable(data);
            dev
        } else {
            Device::new(backend.clone())
        };

        Ok(Self {
            device: Arc::new(RwLock::new(device)),
            backend,
            dirty: Arc::new(AtomicBool::new(false)),
            save_notify: Arc::new(Event::new()),
        })
    }
}

Key Methods

get_device_snapshot

Purpose: Read-only access to device state
pub async fn get_device_snapshot(&self) -> Device {
    self.device.read().await.clone()
}
Usage:
let device = client.persistence_manager.get_device_snapshot().await;
println!("Device ID: {:?}", device.pn);
println!("Push Name: {}", device.push_name);

modify_device

Purpose: Modify device state with automatic dirty tracking
pub async fn modify_device<F, R>(&self, modifier: F) -> R
where
    F: FnOnce(&mut Device) -> R,
{
    let mut device_guard = self.device.write().await;
    let result = modifier(&mut device_guard);

    self.dirty.store(true, Ordering::Relaxed);
    self.save_notify.notify(1);

    result
}
Usage:
client.persistence_manager.modify_device(|device| {
    device.push_name = "New Name".to_string();
}).await;

process_command

Purpose: Apply state changes via DeviceCommand
pub async fn process_command(&self, command: DeviceCommand) {
    self.modify_device(|device| {
        apply_command_to_device(device, command);
    }).await;
}
Usage:
client.persistence_manager
    .process_command(DeviceCommand::SetPushName("New Name".to_string()))
    .await;

Background Saver

Purpose: Periodically persist dirty state to disk
pub fn run_background_saver(self: Arc<Self>, runtime: Arc<dyn Runtime>, interval: Duration) {
    runtime.spawn(Box::pin(async move {
        loop {
            let listener = self.save_notify.listen();
            // Race: wake on notification or after interval
            futures_lite::future::or(listener, async {
                // sleep using runtime abstraction
                runtime.sleep(interval).await;
            }).await;

            if let Err(e) = self.save_to_disk().await {
                error!("Error saving device state: {e}");
            }
        }
    }));
}

async fn save_to_disk(&self) -> Result<(), StoreError> {
    if self.dirty.swap(false, Ordering::AcqRel) {
        let device_guard = self.device.read().await;
        let serializable_device = device_guard.to_serializable();
        drop(device_guard);

        self.backend.save(&serializable_device).await?;
    }
    Ok(())
}
Behavior:
  • Wakes up when notified or after interval
  • Only saves if dirty flag is set
  • Uses optimistic locking (dirty flag)
Start background saver:
let persistence_manager = Arc::new(PersistenceManager::new(backend).await?);
persistence_manager.clone().run_background_saver(runtime.clone(), Duration::from_secs(30));

Backend Trait

Location: wacore/src/store/traits.rs

Overview

The Backend trait is automatically implemented for any type that implements all four domain-specific traits:
pub trait Backend: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync {}

Domain Traits

SignalStore

Purpose: Signal protocol cryptographic operations
use bytes::Bytes; // from the `bytes` crate

#[async_trait]
pub trait SignalStore: Send + Sync {
    // Identity Operations
    async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<()>;
    async fn load_identity(&self, address: &str) -> Result<Option<[u8; 32]>>;
    async fn delete_identity(&self, address: &str) -> Result<()>;

    // Session Operations
    async fn get_session(&self, address: &str) -> Result<Option<Bytes>>;
    async fn put_session(&self, address: &str, session: &[u8]) -> Result<()>;
    async fn delete_session(&self, address: &str) -> Result<()>;
    async fn has_session(&self, address: &str) -> Result<bool>; // has default impl using get_session

    // PreKey Operations
    async fn store_prekey(&self, id: u32, record: &[u8], uploaded: bool) -> Result<()>;
    async fn store_prekeys_batch(&self, keys: &[(u32, Bytes)], uploaded: bool) -> Result<()>; // has default impl looping over store_prekey
    async fn load_prekey(&self, id: u32) -> Result<Option<Bytes>>;
    async fn load_prekeys_batch(&self, ids: &[u32]) -> Result<Vec<(u32, Bytes)>>; // has default impl looping over load_prekey

    async fn remove_prekey(&self, id: u32) -> Result<()>;
    async fn get_max_prekey_id(&self) -> Result<u32>;

    // Signed PreKey Operations
    async fn store_signed_prekey(&self, id: u32, record: &[u8]) -> Result<()>;
    async fn load_signed_prekey(&self, id: u32) -> Result<Option<Vec<u8>>>;
    async fn load_all_signed_prekeys(&self) -> Result<Vec<(u32, Vec<u8>)>>;
    async fn remove_signed_prekey(&self, id: u32) -> Result<()>;

    // Sender Key Operations (for groups)
    async fn put_sender_key(&self, address: &str, record: &[u8]) -> Result<()>;
    async fn get_sender_key(&self, address: &str) -> Result<Option<Vec<u8>>>;
    async fn delete_sender_key(&self, address: &str) -> Result<()>;
}
Usage Example:
// Store identity key for a contact
backend.put_identity(
    "15551234567@s.whatsapp.net:0",
    identity_key
).await?;

// Load session for decryption
if let Some(session) = backend.get_session("15551234567@s.whatsapp.net:0").await? {
    // Decrypt message using session
}

SignalStoreCache

Location: wacore/src/store/signal_cache.rs (re-exported from src/store/signal_cache.rs) The SignalStoreCache provides an in-memory cache layer for Signal protocol state, matching WhatsApp Web’s SignalStoreCache implementation. All crypto operations read and write through this cache, with database writes deferred to explicit flush() calls. Sessions and sender keys are cached as deserialized objects (SessionRecord and SenderKeyRecord respectively), matching WhatsApp Web’s pattern where the JS object IS the cache. Serialization only happens during flush() — not on every store_session or put_sender_key call. Identity stores use Arc<[u8]> byte caches.
pub struct SignalStoreCache {
    sessions: Mutex<SessionStoreState>,
    identities: Mutex<ByteStoreState>,
    sender_keys: Mutex<SenderKeyStoreState>,
}

// Session object cache — no per-message serialize/deserialize
struct SessionStoreState {
    cache: HashMap<Arc<str>, Option<SessionRecord>>,  // None = known-absent
    dirty: HashSet<Arc<str>>,                          // Modified keys pending flush
    deleted: HashSet<Arc<str>>,                        // Deleted keys pending flush
}

// Sender key object cache (same pattern as sessions)
struct SenderKeyStoreState {
    cache: HashMap<Arc<str>, Option<SenderKeyRecord>>,  // None = known-absent
    dirty: HashSet<Arc<str>>,                            // Modified keys pending flush
}

// Byte cache for identities
struct ByteStoreState {
    cache: HashMap<Arc<str>, Option<Arc<[u8]>>>,  // None = known-absent
    dirty: HashSet<Arc<str>>,
    deleted: HashSet<Arc<str>>,
}
Key features:
  • Session object cache: Sessions are stored as SessionRecord objects, eliminating prost encode/decode from the per-message path. Cold loads deserialize from backend bytes once and cache the object; subsequent reads return the cached object directly
  • Sender key object cache: Sender keys are stored as SenderKeyRecord objects (same pattern as sessions), eliminating serialize/deserialize from the per-message group encryption path. Cold loads deserialize from backend bytes once and cache the object
  • Arc previous sessions: SessionRecord.previous_sessions is wrapped in Arc<Vec<SessionStructure>>, making clone O(1) for the ~40 archived sessions. Only rare paths (archive, promote, take/restore) trigger Arc::make_mut
  • Owned store_session: The store_session method takes SessionRecord by value, enabling zero-cost moves from the protocol layer. The compiler enforces no use-after-store
  • Deferred writes: Changes are accumulated in memory and batch-written on flush(). Sessions and sender keys are serialized only during flush(), not on every store
  • Redundant write elimination: For identities (which rarely change), put_dedup() compares incoming bytes against the cached value and skips if identical
  • Negative caching: Known-absent keys are cached as None to avoid repeated DB lookups
  • Independent locking: Sessions, identities, and sender keys each have their own mutex
  • O(1) key cloning: Keys stored as Arc<str> so cloning a key is a refcount bump instead of a heap allocation. The key_for() method reuses existing Arc<str> keys from the HashMap via get_key_value(), avoiding heap allocation on the hot path
  • Single-allocation keys: Session lock keys use to_protocol_address_string() (format: user[:device]@server.0) which builds the key string in one allocation, avoiding the two-allocation overhead of constructing a ProtocolAddress then calling .to_string(). See Signal Protocol performance for details
Cache operations:
// Read (loads from backend if not cached, returns SessionRecord object)
let session = cache.get_session(address, &backend).await?;

// Check existence (cache hit is O(1), cold load caches for subsequent get)
let exists = cache.has_session(address, &backend).await?;

// Write (takes ownership, marks as dirty, doesn't hit backend)
cache.put_session(address, record).await;

// Delete (marks for deletion on flush)
cache.delete_session(address).await;

// Persist all dirty state to backend (sessions serialized here)
cache.flush(&backend).await?;

// Clear cache (on disconnect/reconnect, retains allocated capacity)
cache.clear().await;
Flush behavior:
  • Acquires all three mutexes to ensure consistency
  • Sessions are serialized to bytes only during flush (not on every put_session)
  • Only clears dirty tracking after ALL writes succeed
  • On failure, dirty state is preserved for retry on next flush

AppSyncStore

Purpose: WhatsApp app state synchronization
#[async_trait]
pub trait AppSyncStore: Send + Sync {
    // Sync Keys
    async fn get_sync_key(&self, key_id: &[u8]) -> Result<Option<AppStateSyncKey>>;
    async fn set_sync_key(&self, key_id: &[u8], key: AppStateSyncKey) -> Result<()>;

    // Version Tracking
    async fn get_version(&self, name: &str) -> Result<HashState>;
    async fn set_version(&self, name: &str, state: HashState) -> Result<()>;

    // Mutation MACs
    async fn put_mutation_macs(
        &self,
        name: &str,
        version: u64,
        mutations: &[AppStateMutationMAC],
    ) -> Result<()>;
    async fn get_mutation_mac(&self, name: &str, index_mac: &[u8]) -> Result<Option<Vec<u8>>>;
    async fn delete_mutation_macs(&self, name: &str, index_macs: &[Vec<u8>]) -> Result<()>;
    async fn get_latest_sync_key_id(&self) -> Result<Option<Vec<u8>>>;
}
AppStateSyncKey Structure:
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppStateSyncKey {
    pub key_data: Vec<u8>,
    pub fingerprint: Vec<u8>,
    pub timestamp: i64,
}
Collections:
  • critical_block - Blocked contacts, push names
  • regular_high - Mute settings, starred messages, contact info
  • regular_low - Archive settings, pin settings
  • regular - Other chat settings

ProtocolStore

Purpose: WhatsApp Web protocol-specific storage
#[async_trait]
pub trait ProtocolStore: Send + Sync {
    // Per-Device Sender Key Tracking (matches WA Web's participant.senderKey Map)
    // Updated only AFTER the server ACKs the message stanza,
    // preventing stale entries from network failures mid-send.
    async fn get_sender_key_devices(&self, group_jid: &str) -> Result<Vec<(String, bool)>>;
    async fn set_sender_key_status(&self, group_jid: &str, entries: &[(&str, bool)]) -> Result<()>;
    async fn clear_sender_key_devices(&self, group_jid: &str) -> Result<()>;

    // Clear all sender key device tracking across ALL groups.
    // Called on identity change (raw_id mismatch) to force SKDM redistribution.
    async fn clear_all_sender_key_devices(&self) -> Result<()>;

    // LID-PN Mapping (Long-term ID to Phone Number)
    async fn get_lid_mapping(&self, lid: &str) -> Result<Option<LidPnMappingEntry>>;
    async fn get_pn_mapping(&self, phone: &str) -> Result<Option<LidPnMappingEntry>>;
    async fn put_lid_mapping(&self, entry: &LidPnMappingEntry) -> Result<()>;
    async fn get_all_lid_mappings(&self) -> Result<Vec<LidPnMappingEntry>>;

    // Base Key Collision Detection
    async fn save_base_key(&self, address: &str, message_id: &str, base_key: &[u8]) -> Result<()>;
    async fn has_same_base_key(&self, address: &str, message_id: &str, current_base_key: &[u8]) -> Result<bool>;
    async fn delete_base_key(&self, address: &str, message_id: &str) -> Result<()>;

    // Device Registry
    async fn update_device_list(&self, record: DeviceListRecord) -> Result<()>;
    async fn get_devices(&self, user: &str) -> Result<Option<DeviceListRecord>>;

    // Delete a device list record, forcing a network re-fetch on next query.
    async fn delete_devices(&self, user: &str) -> Result<()>;

    // TcToken Storage (Trusted Contact Tokens)
    async fn get_tc_token(&self, jid: &str) -> Result<Option<TcTokenEntry>>;
    async fn put_tc_token(&self, jid: &str, entry: &TcTokenEntry) -> Result<()>;
    async fn delete_tc_token(&self, jid: &str) -> Result<()>;
    async fn get_all_tc_token_jids(&self) -> Result<Vec<String>>;
    async fn delete_expired_tc_tokens(&self, cutoff_timestamp: i64) -> Result<u32>;

    // Sent Message Store (retry support, matches WA Web's getMessageTable)
    async fn store_sent_message(&self, chat_jid: &str, message_id: &str, payload: &[u8]) -> Result<()>;
    async fn take_sent_message(&self, chat_jid: &str, message_id: &str) -> Result<Option<Vec<u8>>>;
    async fn delete_expired_sent_messages(&self, cutoff_timestamp: i64) -> Result<u32>;
}
LidPnMappingEntry:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LidPnMappingEntry {
    pub lid: String,
    pub phone_number: String,
    pub created_at: i64,
    pub updated_at: i64,
    pub learning_source: String,
}

LidPnCache

Location: src/lid_pn_cache.rs The LidPnCache provides a bounded in-memory cache for LID to phone number mappings, used for Signal address resolution. WhatsApp Web uses LID-based addresses for Signal sessions when available.
pub struct LidPnCache {
    lid_to_entry: Cache<String, LidPnEntry>,
    pn_to_entry: Cache<String, LidPnEntry>,
}
Bounds (prevents unbounded memory growth):
  • Max capacity: 10,000 entries per map
  • Time-to-idle TTL: 1 hour
Bidirectional lookups:
// Get LID for a phone number
let lid = cache.get_current_lid("15551234567").await;

// Get phone number for a LID
let phone = cache.get_phone_number("100000012345678").await;
Timestamp conflict resolution: When multiple LIDs exist for the same phone number, the entry with the most recent created_at timestamp wins for the PN → LID lookup:
// Add mapping (only updates PN map if newer timestamp)
cache.add(entry).await;
Initialization:
// Warm up cache from persistent storage on client init
let entries = backend.get_all_lid_mappings().await?;
cache.warm_up(entries).await;
Session migration on LID discovery: When a new LID-PN mapping is added to the cache, the client automatically migrates any Signal sessions stored under the PN address to the corresponding LID address. The migration reads and writes through the SignalStoreCache (not the backend directly) to avoid stale reads when the cache has unflushed mutations, then flushes the migrated state to the backend. This prevents SessionNotFound decryption failures when the phone switches from PN to LID addressing. See Signal Protocol — PN→LID session migration for details. Learning sources:
  • usync - User sync responses
  • peer_pn_message / peer_lid_message - Peer messages
  • pairing - Device pairing
  • device_notification - Device notifications
  • blocklist_active / blocklist_inactive - Blocklist operations
DeviceListRecord:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceListRecord {
    pub user: String,
    pub devices: Vec<DeviceInfo>,
    pub timestamp: i64,
    pub phash: Option<String>,
    /// ADV raw_id from ADVKeyIndexList — used to detect identity changes.
    /// When this changes, all sessions and sender keys for the user must be cleared.
    pub raw_id: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
    pub device_id: u32,
    pub key_index: Option<u32>,
}
TcTokenEntry:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TcTokenEntry {
    pub token: Vec<u8>,
    pub token_timestamp: i64,
    pub sender_timestamp: Option<i64>,
}

DeviceStore

Purpose: Device data persistence
#[async_trait]
pub trait DeviceStore: Send + Sync {
    async fn save(&self, device: &Device) -> Result<()>;
    async fn load(&self) -> Result<Option<Device>>;
    async fn exists(&self) -> Result<bool>;
    async fn create(&self) -> Result<i32>;
    async fn snapshot_db(&self, _name: &str, _extra_content: Option<&[u8]>) -> Result<()>;
}
Device Structure:
// wacore/src/store/device.rs
#[derive(Clone, Serialize, Deserialize)]
pub struct Device {
    pub pn: Option<Jid>,                    // Phone number JID
    pub lid: Option<Jid>,                   // Long-term identifier
    pub push_name: String,                  // Display name
    pub registration_id: u32,               // Signal registration ID
    pub adv_secret_key: [u8; 32],          // Advertisement secret
    #[serde(with = "key_pair_serde")]
    pub identity_key: KeyPair,              // Signal identity keypair
    #[serde(with = "key_pair_serde")]
    pub noise_key: KeyPair,                 // Noise protocol keypair
    #[serde(with = "key_pair_serde")]
    pub signed_pre_key: KeyPair,            // Signal signed pre-key
    pub signed_pre_key_id: u32,
    #[serde(with = "BigArray")]
    pub signed_pre_key_signature: [u8; 64],
    #[serde(with = "account_serde", default)]
    pub account: Option<wa::AdvSignedDeviceIdentity>,
    pub app_version_primary: u32,
    pub app_version_secondary: u32,
    pub app_version_tertiary: u32,
    pub app_version_last_fetched_ms: i64,
    #[serde(skip)]
    pub device_props: wa::DeviceProps,       // Not persisted
    #[serde(default)]
    pub edge_routing_info: Option<Vec<u8>>,  // Optimized reconnection
    #[serde(default)]
    pub props_hash: Option<String>,          // A/B experiment tracking
    #[serde(default)]
    pub next_pre_key_id: u32,               // Monotonic counter for prekey IDs
    #[serde(default)]
    pub server_has_prekeys: bool,            // Whether server has prekeys uploaded
    #[serde(default)]
    pub nct_salt: Option<Vec<u8>>,           // NCT salt for cstoken computation
    #[serde(skip)]
    pub nct_salt_sync_seen: bool,            // Runtime flag: authoritative sync seen
}
The account field uses a custom account_serde module to bridge prost-generated protobuf types (which lack serde::Deserialize) into serde. It encodes AdvSignedDeviceIdentity to protobuf bytes on serialization and decodes them back on deserialization. The #[serde(default)] attribute ensures backward compatibility — old data missing this field deserializes as None.

SqliteStore implementation

Location: storages/sqlite-storage/src/lib.rs
As of v0.5, the whatsapp-rust-sqlite-storage crate bundles SQLite by default via the bundled-sqlite feature. You no longer need SQLite installed as a system dependency. To link against a system SQLite instead, disable the default features on the crate.

Database schema

The device table uses named columns for each field, making the schema self-documenting and reducing the risk of column mix-ups when fields are added:
-- Device table (named columns, not a serialized blob)
CREATE TABLE device (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    lid TEXT NOT NULL,
    pn TEXT NOT NULL,
    registration_id INTEGER NOT NULL,
    noise_key BLOB NOT NULL,
    identity_key BLOB NOT NULL,
    signed_pre_key BLOB NOT NULL,
    signed_pre_key_id INTEGER NOT NULL,
    signed_pre_key_signature BLOB NOT NULL,
    adv_secret_key BLOB NOT NULL,
    account BLOB,
    push_name TEXT NOT NULL DEFAULT '',
    app_version_primary INTEGER NOT NULL DEFAULT 0,
    app_version_secondary INTEGER NOT NULL DEFAULT 0,
    app_version_tertiary BIGINT NOT NULL DEFAULT 0,
    app_version_last_fetched_ms BIGINT NOT NULL DEFAULT 0,
    edge_routing_info BLOB,
    props_hash TEXT,
    next_pre_key_id INTEGER NOT NULL DEFAULT 0,
    server_has_prekeys BOOLEAN NOT NULL DEFAULT 0,
    nct_salt BLOB
);

-- Signal protocol tables
CREATE TABLE identities (
    address TEXT NOT NULL,
    key BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (address, device_id)
);

CREATE TABLE sessions (
    address TEXT NOT NULL,
    record BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (address, device_id)
);

CREATE TABLE prekeys (
    id INTEGER NOT NULL,
    key BLOB NOT NULL,
    uploaded BOOLEAN NOT NULL DEFAULT FALSE,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (id, device_id)
);

CREATE TABLE signed_prekeys (
    id INTEGER NOT NULL,
    record BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (id, device_id)
);

CREATE TABLE sender_keys (
    address TEXT NOT NULL,
    record BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (address, device_id)
);

-- App state sync tables
CREATE TABLE app_state_keys (
    key_id BLOB NOT NULL,
    key_data BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (key_id, device_id)
);

CREATE TABLE app_state_versions (
    name TEXT NOT NULL,
    state_data BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (name, device_id)
);

CREATE TABLE app_state_mutation_macs (
    name TEXT NOT NULL,
    version BIGINT NOT NULL,
    index_mac BLOB NOT NULL,
    value_mac BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (name, index_mac, device_id)
);

-- Protocol tables
-- Unified per-device sender key tracking (matches WA Web's participant.senderKey Map)
CREATE TABLE sender_key_devices (
    group_jid TEXT NOT NULL,
    device_jid TEXT NOT NULL,
    has_key INTEGER NOT NULL DEFAULT 0,
    device_id INTEGER NOT NULL DEFAULT 1,
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    PRIMARY KEY (group_jid, device_jid, device_id)
);

CREATE TABLE lid_pn_mapping (
    lid TEXT NOT NULL,
    phone_number TEXT NOT NULL,
    created_at BIGINT NOT NULL,
    learning_source TEXT NOT NULL,
    updated_at BIGINT NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (lid, device_id)
);

CREATE TABLE base_keys (
    address TEXT NOT NULL,
    message_id TEXT NOT NULL,
    base_key BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    PRIMARY KEY (address, message_id, device_id)
);

CREATE TABLE device_registry (
    user_id TEXT NOT NULL,
    devices_json TEXT NOT NULL,
    timestamp INTEGER NOT NULL,
    phash TEXT,
    device_id INTEGER NOT NULL DEFAULT 1,
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    PRIMARY KEY (user_id, device_id)
);

CREATE TABLE tc_tokens (
    jid TEXT NOT NULL,
    token BLOB NOT NULL,
    token_timestamp INTEGER NOT NULL,
    sender_timestamp INTEGER,
    device_id INTEGER NOT NULL DEFAULT 1,
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    PRIMARY KEY (jid, device_id)
);

-- Sent message store for retry handling
CREATE TABLE sent_messages (
    chat_jid TEXT NOT NULL,
    message_id TEXT NOT NULL,
    payload BLOB NOT NULL,
    device_id INTEGER NOT NULL DEFAULT 1,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    PRIMARY KEY (chat_jid, message_id, device_id)
);

CREATE INDEX idx_sent_messages_created ON sent_messages (created_at, device_id);

Multi-Account Support

Each device has unique device_id:
use whatsapp_rust::store::SqliteStore;

// Account 1
let backend1 = Arc::new(SqliteStore::new_for_device("whatsapp.db", 1).await?);
let pm1 = PersistenceManager::new(backend1).await?;

// Account 2
let backend2 = Arc::new(SqliteStore::new_for_device("whatsapp.db", 2).await?);
let pm2 = PersistenceManager::new(backend2).await?;
All tables scoped by device_id:
SELECT * FROM sessions WHERE device_id = 1 AND address = ?;

DeviceCommand Pattern

Location: src/store/commands.rs, wacore/src/store/commands.rs

Purpose

Provide type-safe, centralized state mutations.

Command Enum

pub enum DeviceCommand {
    SetId(Option<Jid>),
    SetLid(Option<Jid>),
    SetPushName(String),
    SetPlatform(String),
    SetAccount(Option<wa::AdvSignedDeviceIdentity>),
    SetNextPreKeyId(u32),
    SetNctSalt(Option<Vec<u8>>),
    SetNctSaltFromHistorySync(Vec<u8>),
    // ... more commands
}

Command Application

pub fn apply_command_to_device(device: &mut Device, command: DeviceCommand) {
    match command {
        DeviceCommand::SetId(id) => {
            device.pn = id;
        }
        DeviceCommand::SetLid(lid) => {
            device.lid = lid;
        }
        DeviceCommand::SetPushName(name) => {
            device.push_name = name;
        }
        DeviceCommand::SetPlatform(platform) => {
            device.platform = platform;
        }
        DeviceCommand::SetAccount(account) => {
            device.account = account;
        }
        // ...
    }
}

Usage

// ✅ Correct: Use DeviceCommand
client.persistence_manager
    .process_command(DeviceCommand::SetPushName("New Name".to_string()))
    .await;

// ❌ Wrong: Direct modification
let mut device = client.device.write().await;
device.push_name = "New Name".to_string(); // DON'T DO THIS

State Management Best Practices

Read-Only Access

// Cheap snapshot for read-only access
let device = client.persistence_manager.get_device_snapshot().await;
println!("JID: {:?}", device.pn);

Modifications

// Use process_command for type-safe mutations
client.persistence_manager
    .process_command(DeviceCommand::SetPushName(name))
    .await;

Bulk Operations

// Use modify_device for multiple changes
client.persistence_manager.modify_device(|device| {
    device.push_name = "New Name".to_string();
    device.platform = "Chrome".to_string();
}).await;

Critical Errors

// Create snapshot for debugging crypto failures
client.persistence_manager
    .create_snapshot("decrypt_failure", Some(&failed_message_bytes))
    .await?;

Custom Backend Implementation

Example: PostgreSQL Backend

use async_trait::async_trait;
use wacore::store::traits::*;

pub struct PostgresStore {
    pool: sqlx::PgPool,
    device_id: i32,
}

#[async_trait]
impl SignalStore for PostgresStore {
    async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<()> {
        sqlx::query(
            "INSERT INTO identities (device_id, address, key) 
             VALUES ($1, $2, $3) 
             ON CONFLICT (device_id, address) DO UPDATE SET key = $3"
        )
        .bind(self.device_id)
        .bind(address)
        .bind(&key[..])
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    // ... implement other methods
}

#[async_trait]
impl AppSyncStore for PostgresStore {
    // ... implement methods
}

#[async_trait]
impl ProtocolStore for PostgresStore {
    // ... implement methods
}

#[async_trait]
impl DeviceStore for PostgresStore {
    // ... implement methods
}

// Backend trait automatically implemented

Usage

let backend = Arc::new(PostgresStore::new(pool, device_id));
let pm = PersistenceManager::new(backend).await?;

Migration & Debugging

Database Snapshots

Feature flag: debug-snapshots
[dependencies]
whatsapp-rust = { version = "0.5", features = ["debug-snapshots"] }
Usage:
// Trigger snapshot on critical errors
if let Err(e) = decrypt_message(&msg).await {
    client.persistence_manager
        .create_snapshot(
            &format!("decrypt_failure_{}", msg.id),
            Some(&serialized_message)
        )
        .await?;
    return Err(e);
}
Output:
snapshots/
├── decrypt_failure_1234567890_whatsapp.db
└── decrypt_failure_1234567890_extra.bin

Pluggable cache store

Location: src/cache_store.rs, src/cache_config.rs, wacore/src/store/cache.rs

Overview

By default, whatsapp-rust uses in-process moka caches for group metadata, device lists, device registry, and LID-PN mappings. The pluggable cache store adapter lets you replace any of these with an external backend (Redis, Memcached, etc.) by implementing the CacheStore trait.

PortableCache (WASM-compatible alternative)

When the moka-cache feature is disabled, whatsapp-rust automatically switches to PortableCache — a platform-agnostic, runtime-independent cache implementation suitable for WASM and other non-standard runtimes. It mirrors the moka Cache API surface so call-sites can switch transparently. PortableCache supports:
  • Maximum capacity with oldest-inserted eviction
  • Time-to-live (TTL) — entries expire a fixed duration after insertion
  • Time-to-idle (TTI) — entries expire after a fixed duration of no access
  • Single-flight get_with — concurrent initializations for the same key coalesce into a single call, which is critical for caches storing coordination primitives (mutexes, channels)
All time checks use wacore::time::now_millis instead of std::time::Instant, making it compatible with environments where monotonic clocks are unavailable.
# Disable moka to use PortableCache (e.g., for WASM targets)
[dependencies]
whatsapp-rust = { version = "0.5", default-features = false, features = [
    "sqlite-storage",
    "tokio-transport",
    "tokio-runtime",
    "ureq-client",
    "tokio-native",
    "signal",
    # "moka-cache" omitted — PortableCache is used instead
] }

CacheStore trait

#[async_trait]
pub trait CacheStore: Send + Sync + 'static {
    async fn get(&self, namespace: &str, key: &str) -> anyhow::Result<Option<Vec<u8>>>;
    async fn set(&self, namespace: &str, key: &str, value: &[u8], ttl: Option<Duration>) -> anyhow::Result<()>;
    async fn delete(&self, namespace: &str, key: &str) -> anyhow::Result<()>;
    async fn clear(&self, namespace: &str) -> anyhow::Result<()>;
    async fn entry_count(&self, namespace: &str) -> anyhow::Result<u64> { Ok(0) }
}
Each logical cache uses a unique namespace string (e.g., "group", "device", "lid_pn_by_lid"). Implementations should partition keys by namespace — for example, a Redis implementation might prefix keys as {namespace}:{key}. Cache operations are best-effort. The client falls back gracefully when cache reads fail (treats as miss) and logs warnings on write failures.

TypedCache

TypedCache<K, V> is a generic wrapper that dispatches to either moka or a custom CacheStore backend. The moka path has zero extra overhead — values are stored in-process without any serialization. The custom-store path serializes values with serde_json and keys via Display.
// Moka path (zero overhead)
let cache = TypedCache::from_moka(moka_cache);

// Custom store path (serde_json serialization)
let cache = TypedCache::from_store(store, "group", Some(Duration::from_secs(3600)));
CacheEntryConfig provides a build_typed_ttl convenience method that automatically selects the right backend: if a custom CacheStore is provided, it creates a TypedCache backed by the store; otherwise it falls back to an in-process moka cache.
let cache: TypedCache<String, GroupInfo> = config.group_cache.build_typed_ttl(
    config.cache_stores.group_cache.clone(),
    "group",
);

invalidate_all and clear

TypedCache provides two ways to remove all entries:
  • invalidate_all() — synchronous. For moka backends this works immediately. For custom CacheStore backends, it spawns a fire-and-forget task via tokio::runtime::Handle::try_current(), which requires the tokio-runtime feature. Without tokio-runtime enabled, the clear is skipped and a warning is logged.
  • clear() — async. Awaits completion for custom backends and is the recommended approach when you need to ensure all entries are removed.
If you disable the tokio-runtime feature and use a custom CacheStore backend, invalidate_all() will silently skip clearing the external store. Use the async clear() method instead.

CacheStores configuration

The CacheStores struct controls which caches use custom backends:
pub struct CacheStores {
    pub group_cache: Option<Arc<dyn CacheStore>>,
    pub device_registry_cache: Option<Arc<dyn CacheStore>>,
    pub lid_pn_cache: Option<Arc<dyn CacheStore>>,
}
Fields left as None keep the default moka behavior. Use CacheStores::all(store) to set the same backend for all pluggable caches at once.
Coordination caches (session_locks, chat_lanes), the signal write-behind cache, and pdo_pending_requests always stay in-process — they hold live Rust objects (mutexes, channel senders) that cannot be serialized to an external store.

Usage with Bot builder

use whatsapp_rust::{CacheConfig, CacheStores};
use std::sync::Arc;

let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379"));

let bot = Bot::builder()
    .with_backend(backend)
    .with_transport_factory(transport)
    .with_http_client(http_client)
    .with_cache_config(CacheConfig {
        cache_stores: CacheStores {
            group_cache: Some(redis.clone()),
            device_registry_cache: Some(redis.clone()),
            ..Default::default()
        },
        ..Default::default()
    })
    .build()
    .await?;
Or use CacheStores::all() to route all pluggable caches to the same backend:
let config = CacheConfig {
    cache_stores: CacheStores::all(redis.clone()),
    ..Default::default()
};
See Custom backends guide for a full implementation example.

Granular cache patching

Instead of invalidating a cache entry and re-fetching from the server on every change notification, whatsapp-rust applies granular patches to cached values in place. This eliminates an extra IQ round-trip per update and keeps caches consistent in real time.

How it works

All patching follows the same pattern: read the cached value, mutate it, and write it back. There is no atomic compare-and-swap — the get → mutate → insert sequence can race with concurrent notifications, but this is acceptable because the cache is best-effort and a full server fetch on the next read corrects any drift.
// Pseudocode for the patching pattern
if let Some(mut cached) = cache.get(&key).await {
    cached.apply_change(notification_data);
    cache.insert(key, cached).await;
}
// If the key isn't cached, the patch is a no-op —
// the next read fetches authoritative state from the server.

Patched cache domains

Granular patching is implemented in three areas: Device registry (src/client/device_registry.rs) When a device notification arrives, the client patches the device_registry_cache (keyed by user string, stores DeviceListRecord):
  • patch_device_add — appends a new device JID to the cached list and persists the updated DeviceListRecord to the backend store
  • patch_device_remove — removes a device by ID using retain, then persists
  • patch_device_update — updates key_index on an existing device entry, then persists
All three methods iterate over every known PN/LID alias for the user so stale alternate-key entries stay consistent. Device patches also persist to the backend store immediately, so changes survive cache eviction and restarts. The patch_device_add method also performs ADV (Account Device Verification) key index filtering. When KeyIndexInfo is present, the signed bytes are decoded via wacore::adv::decode_key_index_list to extract valid device indexes, and stale devices not in the valid set are filtered out. If the raw_id in the notification differs from the stored value, the client detects an identity change and clears all Signal sessions for that user’s non-primary devices. When a notification arrives with only a hash (no device list), the client falls back to full invalidation and lets the next access re-fetch from the server. LID migration: When a new LID-PN mapping is discovered, the device registry re-keys entries from the PN key to the LID key via migrate_device_registry_on_lid_discovery(). This ensures lookups by either addressing scheme resolve to the same canonical record. The migration also invalidates stale PN-keyed entries from both cache and database. Group metadata (src/handlers/notification.rs, src/features/groups.rs) When participant add/remove notifications arrive, the cached GroupInfo is patched in place:
  • Participant adds use GroupInfo::add_participants(), which deduplicates by user and backfills LID-to-PN maps for LID-addressed groups
  • Participant removes use GroupInfo::remove_participants(), which also cleans up both LID-to-PN and PN-to-LID maps bidirectionally
  • API calls (client.groups().add_participants(), client.groups().remove_participants()) also patch the cache after a successful server response, filtering to only participants the server accepted (status 200)
Group patches are cache-only — they are not persisted to the backend. If the cache entry is evicted, the next query_info() call re-fetches from the server. Only leave() uses full invalidation. LID-PN mappings (src/lid_pn_cache.rs) The LidPnCache uses timestamp-based conflict resolution when adding new mappings. The PN → entry map only updates if the new entry’s created_at is newer than or equal to the existing entry’s, preventing older stale mappings from overwriting newer ones.

Patching vs. invalidation summary

BehaviorGranular patchInvalidate + refetch
Network costZero — applies diff locallyOne IQ round-trip per cache miss
LatencyImmediate updateStale until next access
Used whenDevice add/remove/update, group participant changes, LID-PN discoveryGroup leave, hash-only device update
AtomicityNot atomic (can race, corrected on next full fetch)N/A
PersistenceDevice patches persist immediately; group patches are cache-onlyBackend remains authoritative

Architecture

Understand PersistenceManager’s role

Authentication

Learn how session data is persisted

Custom Backends

Implement your own storage backend

Storage API

Complete storage API reference