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}