Skip to main content

mail4agent_store_sqlite/
migrations.rs

1//! The mailbox's schema, as versioned [`crate::db::Migration`]s.
2//!
3//! One migration per line of this file's history: a shipped migration's
4//! `sql` is never edited after it has run anywhere, because
5//! [`crate::db::MigrationRunner`] records completion by version number alone
6//! -- changing a version's SQL after the fact would leave already-migrated
7//! databases silently out of sync with a fresh one. A schema change is
8//! always a new, higher-numbered [`crate::db::Migration`] appended to
9//! [`migrations`], never an edit to an existing entry.
10
11use crate::db::Migration;
12
13/// `v1`: the mailbox's whole schema -- participants, rooms, room
14/// membership, messages, acks, and the idempotency ledger. Six tables,
15/// because this crate is new and none of them have shipped independently
16/// yet; a later schema change appends `v2`, `v3`, ... here rather than
17/// touching this string.
18///
19/// Column and index choices, table by table:
20///
21/// - `participants.secret_digest` is a `BLOB` of the raw 32-byte SHA-256
22///   digest, not lower-hex text: it is only ever compared byte-for-byte
23///   (see `mail4agent_core::MailboxEngine::authenticate`) and never
24///   displayed or logged, so a text encoding would only cost bytes on
25///   disk and cycles on every authentication for no benefit.
26///   `idx_participants_secret_digest` is the index that lookup runs
27///   against, `UNIQUE` because two participants sharing a digest would
28///   mean two participants sharing a secret.
29/// - `room_members` is queried both ways -- "members of a room" (its
30///   primary key's leading column, `room_id`) and "rooms containing a
31///   participant" (`idx_room_members_participant`, the reverse). It
32///   carries a foreign key on `room_id` only: rooms are never deleted by
33///   this store (there is no such `MailStore` method), so that constraint
34///   is free to enforce and catches a real bug -- a membership row naming
35///   a room that was never created. It deliberately carries **no**
36///   foreign key on `participant_id`: `MailStore::deregister_participant`
37///   leaves a departed participant's membership rows in place on purpose
38///   (they are inert, never a leak -- see `mail4agent_core::store`'s
39///   module doc comment), and a hard foreign key here would turn that
40///   into a delete failure the first time a deregistered participant had
41///   ever joined a room.
42/// - `messages` splits its columns the same way the mailbox this crate
43///   replaces did, and for the same reason: `to_kind` /
44///   `to_participant` / `to_room` / `created_at_unix_ms` are exactly what
45///   `direct_messages_since` / `room_messages_since` filter and order on,
46///   so they are real columns with matching partial indexes
47///   (`idx_messages_direct_recipient`, `idx_messages_room_recipient` --
48///   partial so a room-addressed row is never scanned while looking for
49///   direct mail, and vice versa). `subject`, `body`, `reply_to`,
50///   `correlation` and `refs` are never filtered or ordered on anywhere in
51///   `MailStore` -- every caller reads them back verbatim -- so they live
52///   together in one JSON `payload` column rather than five more columns
53///   that would need a migration each time this crate's callers wanted a
54///   new field on a message.
55/// - `acks` is keyed by `(message_id, reader)` directly as its primary
56///   key: a room-addressed message has one ack per reader, never one
57///   total, and that primary key is also the exact shape `get_ack` and
58///   `record_ack`'s idempotent upsert (see `store.rs`) key against.
59/// - `idempotency` is keyed by `(sender, idempotency_key)` as its primary
60///   key -- not a secondary `UNIQUE` index over a surrogate id -- so
61///   `insert_message`'s check-and-insert is enforced by that constraint
62///   directly: a repeat send racing the same key fails the `INSERT`
63///   itself rather than a read-then-write pair a second caller could
64///   interleave with.
65const SCHEMA_V1_SQL: &str = "
66CREATE TABLE participants (
67    id            TEXT PRIMARY KEY,
68    label         TEXT,
69    secret_digest BLOB NOT NULL,
70    may_send      INTEGER NOT NULL,
71    may_read      INTEGER NOT NULL,
72    operator      INTEGER NOT NULL
73);
74
75CREATE UNIQUE INDEX idx_participants_secret_digest ON participants(secret_digest);
76
77CREATE TABLE rooms (
78    id                 TEXT PRIMARY KEY,
79    created_at_unix_ms INTEGER NOT NULL
80);
81
82CREATE TABLE room_members (
83    room_id        TEXT NOT NULL REFERENCES rooms(id),
84    participant_id TEXT NOT NULL,
85    PRIMARY KEY (room_id, participant_id)
86);
87
88CREATE INDEX idx_room_members_participant ON room_members(participant_id);
89
90CREATE TABLE messages (
91    message_id         TEXT PRIMARY KEY,
92    from_participant   TEXT NOT NULL,
93    to_kind            TEXT NOT NULL CHECK (to_kind IN ('direct', 'room')),
94    to_participant     TEXT,
95    to_room            TEXT,
96    created_at_unix_ms INTEGER NOT NULL,
97    payload            TEXT NOT NULL
98);
99
100CREATE INDEX idx_messages_direct_recipient
101    ON messages(to_participant, created_at_unix_ms)
102    WHERE to_kind = 'direct';
103
104CREATE INDEX idx_messages_room_recipient
105    ON messages(to_room, created_at_unix_ms)
106    WHERE to_kind = 'room';
107
108CREATE TABLE acks (
109    message_id       TEXT NOT NULL,
110    reader           TEXT NOT NULL,
111    acked_at_unix_ms INTEGER NOT NULL,
112    PRIMARY KEY (message_id, reader)
113);
114
115CREATE TABLE idempotency (
116    sender          TEXT NOT NULL,
117    idempotency_key TEXT NOT NULL,
118    message_id      TEXT NOT NULL,
119    PRIMARY KEY (sender, idempotency_key)
120);
121";
122
123/// `v2`: sessions, plus room for a session on both sides of a message's
124/// envelope. **Never edits `SCHEMA_V1_SQL` above** -- a live database
125/// (the mailbox on 18301) is at v1 right now with real participants,
126/// rooms and messages in it, and every one of those rows must still read
127/// back exactly as before once this migration has run.
128///
129/// - `sessions` is new. Keyed on `session_id` alone, not on `(account,
130///   session_id)`, because `MailStore::get_session` looks a session up
131///   by its id with no account in hand yet -- that lookup is exactly how
132///   `MailboxEngine::ensure_session` and `::resolve_identity` *learn*
133///   which account owns a session, so the primary key has to support it
134///   without the account as an input. `idx_sessions_account` is the
135///   reverse index `sessions_of` runs against, playing the same role
136///   `idx_room_members_participant` already plays for `rooms_containing`.
137///   `card` carries the session's whole `SessionCard` -- its
138///   `attested`/`corroborated`/`declared` groups together -- as one JSON
139///   blob, the same choice `messages.payload` already made for this
140///   crate's free-form fields: nothing inside a `SessionCard` is filtered
141///   or ordered on by any `MailStore` method, so it earns no columns of
142///   its own. `account` and `last_seen_unix_ms` do get real columns
143///   because `sessions_of` filters on the former and `ensure_session`
144///   refreshes the latter on every call.
145///
146/// - `messages` gains a session shape on **both** sides of the envelope.
147///   `to_kind`'s `CHECK` widens from `('direct', 'room')` to `('direct',
148///   'session', 'room')`, with a new `to_session` column alongside the
149///   existing `to_participant`/`to_room` -- a session recipient gets its
150///   own column rather than sharing `to_participant` with a direct
151///   account address, so a query can never confuse the two kinds even if
152///   it forgot to check `to_kind` first. `from_participant` was `NOT
153///   NULL` and singular in v1 because nothing but an account could ever
154///   send a message then; it now gains a sibling `from_kind` (defaulted
155///   to `'direct'`, matching what every v1 row actually means) and a
156///   nullable `from_session`, mirroring the `to` side exactly.
157///
158///   SQLite has no `ALTER TABLE ... DROP CONSTRAINT` (or any way to
159///   widen one in place), so loosening `to_kind`'s `CHECK` means
160///   rebuilding the table: create the v2 shape under a temporary name,
161///   copy every v1 row across (`from_kind` literally `'direct'`,
162///   `to_session` literally `NULL` -- the only values a v1 row could
163///   ever have meant), drop the v1 table, rename the new one into place,
164///   and recreate every index the old table carried (both survive
165///   unchanged: `idx_messages_direct_recipient`,
166///   `idx_messages_room_recipient`) plus the new
167///   `idx_messages_session_recipient`, indexed the same way the rest of
168///   this table is -- `messages_to_since` and `room_messages_since` are
169///   both still exactly "messages for this address, no older than this
170///   time".
171///
172/// - `acks` and `idempotency` need **no schema change at all**. Their
173///   `reader`/`sender` columns were always plain `TEXT`, and a v1 row's
174///   value there was already exactly an account's [`mail4agent_api::Address::Direct`]
175///   `Display` form (`"claude"`) -- the same string
176///   `mail4agent_store_sqlite::store::address_text` still writes for a
177///   direct address today. A session's `Display` form (`"claude/s-7f3a..."`)
178///   is simply a longer string in the same column; the two can never
179///   collide because neither a participant id's nor a session id's
180///   charset permits `/` (see `Address`'s own `Display`/`FromStr` doc
181///   comment in `mail4agent-api`). This is the one place the
182///   address-as-a-key change turned out to cost nothing: only the
183///   `messages` table's structured, per-kind columns needed rebuilding.
184const SCHEMA_V2_SQL: &str = "
185CREATE TABLE sessions (
186    session_id        TEXT PRIMARY KEY,
187    account           TEXT NOT NULL,
188    card              TEXT NOT NULL,
189    last_seen_unix_ms INTEGER NOT NULL
190);
191
192CREATE INDEX idx_sessions_account ON sessions(account);
193
194CREATE TABLE messages_v2 (
195    message_id         TEXT PRIMARY KEY,
196    from_participant   TEXT NOT NULL,
197    from_kind          TEXT NOT NULL DEFAULT 'direct' CHECK (from_kind IN ('direct', 'session')),
198    from_session       TEXT,
199    to_kind            TEXT NOT NULL CHECK (to_kind IN ('direct', 'session', 'room')),
200    to_participant     TEXT,
201    to_session         TEXT,
202    to_room            TEXT,
203    created_at_unix_ms INTEGER NOT NULL,
204    payload            TEXT NOT NULL
205);
206
207INSERT INTO messages_v2 (
208    message_id, from_participant, from_kind, from_session,
209    to_kind, to_participant, to_session, to_room, created_at_unix_ms, payload
210)
211SELECT message_id, from_participant, 'direct', NULL,
212       to_kind, to_participant, NULL, to_room, created_at_unix_ms, payload
213FROM messages;
214
215DROP TABLE messages;
216
217ALTER TABLE messages_v2 RENAME TO messages;
218
219CREATE INDEX idx_messages_direct_recipient
220    ON messages(to_participant, created_at_unix_ms)
221    WHERE to_kind = 'direct';
222
223CREATE INDEX idx_messages_session_recipient
224    ON messages(to_participant, to_session, created_at_unix_ms)
225    WHERE to_kind = 'session';
226
227CREATE INDEX idx_messages_room_recipient
228    ON messages(to_room, created_at_unix_ms)
229    WHERE to_kind = 'room';
230";
231
232/// `v3`: one nullable column, `participants.listener_url` -- the URL the
233/// mailbox POSTs a `mail4agent_api::DeliveryNotification` to when mail
234/// arrives for that account or any of its sessions
235/// (`mail4agent_core::MailboxEngine::set_listener`/`remove_listener`).
236/// `NULL` until an operator registers one via `POST /admin/listener`, so
237/// every existing row reads back with no listener on file -- no data
238/// migration needed, unlike `v2`'s `messages` rebuild: SQLite's own
239/// `ALTER TABLE ... ADD COLUMN` handles a nullable column with no `CHECK`
240/// in one statement.
241const SCHEMA_V3_SQL: &str = "
242ALTER TABLE participants ADD COLUMN listener_url TEXT;
243";
244
245/// This crate's own migrations, in the order [`crate::db::MigrationRunner`]
246/// must apply them. A daemon runs these once against the [`crate::db::Db`] it
247/// hands to [`crate::SqliteMailStore::new`]; [`crate::SqliteMailStore::open_in_memory`]
248/// runs them itself for tests and small tools.
249pub fn migrations() -> Vec<Migration> {
250    vec![
251        Migration::new(1, "mail4agent_v1_schema", SCHEMA_V1_SQL),
252        Migration::new(2, "mail4agent_v2_sessions_and_session_addressing", SCHEMA_V2_SQL),
253        Migration::new(3, "mail4agent_v3_participant_listener_url", SCHEMA_V3_SQL),
254    ]
255}