--- type: concept title: Message history store description: The local SQLite database in src/history.rs that stores messages, chat summaries, contacts, the per-chat AI autoreply flag and the global autoreply setting, and supports local chat deletion. tags: [sqlite, rusqlite, history, persistence, schema] sources: - id: openwiki-source-ce0c4ea8cd3b15431fa691ce resource: repo://src/history.rs generated: { by: "claude-code", at: "2026-10-20T20:27:06.515Z" } verified: - by: openwiki/0.7.2 at: 2026-10-20T20:27:06.515Z --- # Message history store `src/history.rs` wraps a single `rusqlite::Connection` in the `History` struct. It is the only persistent state owned by the app itself (the WhatsApp session lives in a separate database, see [Configuration and data](../operations/configuration-and-data.md)). [`App`](../architecture/app-state.md) owns the `History` and calls it synchronously from the event loop. ## Opening - `History::open(path)` (`src/history.rs:65-74`) creates missing parent directories, opens the file and runs the schema script. - `History::open_in_memory()` (`src/history.rs:80-86`) exists only under `#[cfg(test)]`. - The schema is applied with `CREATE TABLE/INDEX IF NOT EXISTS` (`schema_sql`, `src/history.rs:344-374`); there are no migrations, so an existing `history.db` simply gains the new `settings` table on next start. ## Schema | Table | Columns | Notes | |---|---|---| | `messages` | `message_id` (PK), `chat_jid`, `sender_jid`, `sender_name`, `text`, `timestamp`, `from_me`, `is_ai` | Index `idx_messages_chat_timestamp` on `(chat_jid, timestamp)` | | `chats` | `jid` (PK), `name`, `last_timestamp` (default 0), `autoreply` (default 0) | One row per chat | | `contacts` | `jid` (PK), `name` (NOT NULL) | Names from the address book / push names | | `settings` | `key` (PK), `value` (INTEGER NOT NULL) | App-wide flags; currently only `global_autoreply` | Rust types (`src/history.rs:26-53`): `StoredMessage`, `ChatSummary` (`jid`, `name`, `last_timestamp`, `autoreply`) and `Contact` (`jid`, `name`). ## Saving messages and deduplication `save_message` (`src/history.rs:105-116`) runs in one transaction: 1. `INSERT OR IGNORE` into `messages` — the WhatsApp message id is the primary key, so redelivered messages are ignored (`src/history.rs:380-402`). 2. Only if the row was new, upsert the `chats` row (`upsert_chat_for_message`, `src/history.rs:408-421`): `last_timestamp` keeps the maximum seen and the name is set only when the chat has no non-empty name yet. 3. Return `true` for a new message, `false` for a duplicate. The chat name candidate is the trimmed sender push name of an **incoming** message; own messages never name a chat (`chat_name_candidate`, `src/history.rs:427-437`). The boolean return value drives the app: duplicates do not refresh the UI or trigger the [AI autoreply](../workflows/ai-autoreply.md). ## Reading - `recent_messages(chat_jid, limit)` (`src/history.rs:122-140`): the latest `limit` messages, queried newest first (`timestamp DESC, rowid DESC`) and reversed to ascending order for display (the open chat view) and for the last-message lookup of the startup catch-up. - `messages_since(chat_jid, since, limit)` (`src/history.rs:148-166`): the messages with `timestamp >= since` (inclusive), capped to the `limit` most recent ones (`timestamp DESC, rowid DESC LIMIT ?3`) and reversed to ascending order. The [AI autoreply](../workflows/ai-autoreply.md) uses it to load the context from local midnight of yesterday, at most `CONTEXT_MESSAGES` messages. - `list_chats()` (`src/history.rs:173-189`): all chats ordered by `last_timestamp DESC`; the name is the contact name, else the chat name, else the user part of the JID (text before `@`). - `list_contacts()` (`src/history.rs:215-234`): ordered by name, case-insensitive. - `display_name(jid)` (`src/history.rs:240-251`): same contact → chat → JID user part fallback, used for the open chat title. ## Contacts `upsert_contact` (`src/history.rs:195-209`) inserts or renames a contact and ignores blank names, so an empty push name never erases a known name. ## Autoreply flags - `set_autoreply(chat_jid, enabled)` (`src/history.rs:257-268`) upserts the `chats` row, creating it with defaults if the chat has no messages yet. - `is_autoreply(chat_jid)` (`src/history.rs:274-283`) returns the stored flag or `false` when the chat is unknown. - `set_global_autoreply(enabled)` / `is_global_autoreply()` (`src/history.rs:289-315`) upsert and read the `global_autoreply` row of `settings`; the default is `false` when it was never stored. The global flag is independent of the per-chat flags: toggling it never changes a `chats.autoreply` value. New messages never reset the per-chat flag, because the message upsert only touches `last_timestamp` and `name`. ## Deleting a chat `delete_chat(chat_jid)` (`src/history.rs:322-337`) removes, in one transaction, all messages of the chat and its `chats` row (and therefore its per-chat autoreply flag). The contact row, other chats and the WhatsApp session are untouched; deleting an unknown chat is not an error, and a later message simply recreates the chat. The app calls it after a double Delete press in the chat list (see [Application state](../architecture/app-state.md)). ## Tests `src/history.rs:481-763` covers duplicate ids, limit and ordering, `messages_since` (boundary inclusion, limit keeping the most recent, ascending order), flag and sender-name round trips, chat ordering, name fallbacks, chat naming rules, per-chat autoreply defaults and persistence, the global autoreply default, toggle and independence from chat flags, `delete_chat` (messages and row gone, contact and other chats kept), contact ordering and blank names, and on-disk persistence with parent directory creation. See [Unit tests](../testing/unit-tests.md).