--- 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, the global autoreply setting and the AI descriptions of images, 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-21T21:49:12.291Z" } verified: - by: openwiki/0.7.2 at: 2026-10-21T21:49:12.291Z --- # 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:67-76`) creates missing parent directories, opens the file and runs the schema script. - `History::open_in_memory()` (`src/history.rs:82-88`) exists only under `#[cfg(test)]`. - The schema is applied with `CREATE TABLE/INDEX IF NOT EXISTS` (`schema_sql`, `src/history.rs:397-436`); there are no migrations, so an existing `history.db` simply gains new tables (such as `settings` and `image_descriptions`) 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` | | `image_descriptions` | `message_id` (PK), `chat_jid`, `description` | AI description of an image message, written once per image | Rust types (`src/history.rs:28-55`): `StoredMessage`, `ChatSummary` (`jid`, `name`, `last_timestamp`, `autoreply`) and `Contact` (`jid`, `name`). ## Saving messages and deduplication `save_message` (`src/history.rs:107-118`) 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:438-463`). 2. Only if the row was new, upsert the `chats` row (`upsert_chat_for_message`, `src/history.rs:465-483`): `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:485-499`). 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:124-142`): 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:150-168`): 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:175-191`): 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:217-236`): ordered by name, case-insensitive. - `display_name(jid)` (`src/history.rs:242-253`): same contact → chat → JID user part fallback, used for the open chat title. ## Contacts `upsert_contact` (`src/history.rs:197-211`) 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:259-270`) upserts the `chats` row, creating it with defaults if the chat has no messages yet. - `is_autoreply(chat_jid)` (`src/history.rs:276-285`) returns the stored flag or `false` when the chat is unknown. - `set_global_autoreply(enabled)` / `is_global_autoreply()` (`src/history.rs:291-317`) 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`. ## Image descriptions The [AI autoreply](../workflows/ai-autoreply.md) asks the vision model to describe each image once and stores the text, so later replies reuse it instead of downloading and describing the image again: - `message_exists(message_id)` (`src/history.rs:319-332`) tells whether a message is still stored; the app checks it before saving a description, so a chat deleted while its images were being described gets no orphan rows. - `save_image_description(message_id, chat_jid, description)` (`src/history.rs:334-349`) upserts the row, replacing a previous description of the same message. - `image_descriptions(chat_jid)` (`src/history.rs:351-367`) returns the chat's descriptions keyed by message id; the app uses it to skip images already described and to turn image messages into text when building the AI context. ## Deleting a chat `delete_chat(chat_jid)` (`src/history.rs:369-394`) removes, in one transaction, all messages of the chat, their image descriptions 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:543` onward 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, `message_exists`, image descriptions (per chat, replaced, deleted with the chat), `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).