Skip to main content

mail4agent_server/
store.rs

1//! Matrix-shaped event store — rooms/events/state/members/relations/
2//! receipts/account-data/txn-dedup/filters half of `messenger.db` (the
3//! devices/keys/backup half is `matrix_keys_store.rs`, a separate work
4//! item). See `the messenger protocol notes`
5//! §2 for the full DDL this module implements (the "Manager decisions on
6//! this plan" section at the top of that file overrides the body — this
7//! module follows those corrections, noted inline where they apply) and §1
8//! for the id-format rules.
9//!
10//! This server adopts Matrix's own event envelope, state-resolution model,
11//! and CS API shape for everything except federation and `/login` — CS API
12//! v1.19, room version 11. Crypto stays entirely client-side: every
13//! `content` blob this module stores is opaque JSON it never inspects
14//! except for `m.relates_to` (relation bookkeeping, kept in cleartext at
15//! the top level of `content` even for `m.room.encrypted`, per the Matrix
16//! spec's own accepted trade-off — plan §10 item 3) and the redaction
17//! allow-list (§2's `redact_event`, which strips by `event_type`, never by
18//! interpreting the payload's meaning).
19//!
20//! # Single-writer discipline
21//!
22//! Every function here takes an already-open [`Connection`]; the caller
23//! (`state.rs`, from P14 onward) holds it behind one `std::sync::Mutex`,
24//! the same discipline `db`/`social_db` already use — this module never
25//! locks anything itself and never calls `std::sync::Mutex` internally.
26//! That single-writer guarantee is what makes [`next_stream_id`] safe: it
27//! is always called inside the same transaction as the row it stamps, and
28//! there is never a second writer racing it.
29//!
30//! # Cross-database rule
31//!
32//! This module stores `user_id INTEGER`. The nick lives on
33//! `messenger_sessions`, which [`crate::nick`] reads and writes. The
34//! `matrix_users.nick` column is left in place and is not the source of
35//! truth. There is no identity database. `matrix_users` maps `user_id` to
36//! its Matrix id (`mxid`) once: `public_id` is immutable, so the mapping
37//! does not change.
38//!
39//! The one place a label does end up in this database is the `displayname`
40//! field of an `m.room.member` event's `content` — the Matrix convention,
41//! and what a client names a DM and lists members by. Callers stamp the
42//! identity database's effective label into every `join`/`invite` member
43//! event they write, and [`refresh_member_displayname`] re-stamps it into
44//! the user's existing member events when the label changes.
45
46use rusqlite::{params, Connection, OptionalExtension, Transaction};
47use std::collections::HashSet;
48use std::fmt;
49
50// ============================================================================
51// Server name, id formats
52// ============================================================================
53
54/// The one Matrix server name this deployment ever answers as — used
55/// everywhere an mxid/room id is formatted or parsed. Never a second
56/// literal (plan §1).
57static SERVER_NAME_CELL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
58
59/// Set the homeserver name once, before any mxid or room id is minted.
60/// The default until this runs is `example.org`.
61pub fn set_matrix_server_name(name: impl Into<String>) -> Result<(), &'static str> {
62    let name = name.into();
63    if name.is_empty() || name.contains(':') || name.contains('/') {
64        return Err("server name must be one hostname");
65    }
66    SERVER_NAME_CELL.set(name).map_err(|_| "server name already set")
67}
68
69static LOCAL_ALIASES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
70
71
72/// Local DNS aliases of the one homeserver are a server-name CHECK only: an mxid addressed to an enabled
73/// alias resolves to the same local account. No second homeserver exists and ids are always minted
74/// with [`matrix_server_name()`]. Off by default.
75///
76/// Accept `names` (hostnames) as local aliases of [`matrix_server_name()`] when
77/// parsing mxids. Call once at boot (the server reads `M4A_LOCAL_NAMES`,
78/// comma-separated). Empty or repeated calls are ignored.
79pub fn set_local_aliases(names: impl IntoIterator<Item = String>) {
80    let list: Vec<String> = names.into_iter().map(|n| n.trim().to_ascii_lowercase()).filter(|n| !n.is_empty() && !n.contains(':') && !n.contains('/')).collect();
81    let _ = LOCAL_ALIASES.set(list);
82}
83
84/// Whether `name` is the minted server name or an enabled local alias.
85pub fn is_local_server_name(name: &str) -> bool {
86    name == matrix_server_name() || LOCAL_ALIASES.get().is_some_and(|aliases| aliases.iter().any(|a| a.eq_ignore_ascii_case(name)))
87}
88
89/// Homeserver name used when minting and parsing local ids.
90pub fn matrix_server_name() -> &'static str {
91    SERVER_NAME_CELL.get_or_init(|| "example.org".to_string()).as_str()
92}
93
94/// Room version pinned for every room this server creates (plan §1) — v11
95/// still lists the creator explicitly in `m.room.power_levels.users`,
96/// unlike v12's "infinite implicit power" simplification, which this plan
97/// does not adopt.
98pub const MATRIX_ROOM_VERSION: &str = "11";
99
100/// Ceiling on the serialized `content` of any state/timeline event this
101/// server accepts (plan §3.8, mirroring `dm_db::DM_MAX_CIPHERTEXT_BYTES`) —
102/// checked by every route that takes client-supplied event content
103/// (`routes::matrix::rooms::put_state` first; messaging/to-device pieces
104/// reuse the same constant rather than minting their own).
105pub const MATRIX_EVENT_CONTENT_MAX_BYTES: usize = 16 * 1024;
106
107/// Localpart prefix reserved for a future Application Service (bridge)
108/// namespace (plan §10 item 6). `matrix_store`'s own id generators never
109/// mint a localpart starting with this — see [`ensure_matrix_user`] — so a
110/// later AS registration slots in without a retroactive id-collision audit.
111pub const RESERVED_LOCALPART_PREFIX: &str = "_bridge_";
112
113/// True if `localpart` is reserved for the future Application Service
114/// namespace (plan §10 item 6) and must never be minted as a real user's
115/// mxid localpart.
116pub fn is_reserved_localpart(localpart: &str) -> bool {
117    localpart.starts_with(RESERVED_LOCALPART_PREFIX)
118}
119
120/// Why parsing an mxid failed.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum MatrixIdError {
123    /// Did not start with `@`.
124    MissingSigil,
125    /// No `:server_name` suffix at all.
126    MissingServerName,
127    /// Addressed to a server other than [`matrix_server_name()`] — this
128    /// deployment does not federate, so such an id can never resolve to a
129    /// real local account.
130    ForeignServerName,
131}
132
133impl fmt::Display for MatrixIdError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            MatrixIdError::MissingSigil => write!(f, "mxid is missing its '@' sigil"),
137            MatrixIdError::MissingServerName => write!(f, "mxid is missing a ':server_name' suffix"),
138            MatrixIdError::ForeignServerName => write!(f, "mxid is addressed to a foreign server name"),
139        }
140    }
141}
142
143/// Format `public_id` (the identity database's immutable `users.public_id`)
144/// as this server's own mxid: `@<public_id>:example.org`.
145pub fn mxid_for_public_id(public_id: &str) -> String {
146    format!("@{public_id}:{}", matrix_server_name())
147}
148
149/// Parse `@localpart:server_name`, returning the localpart — refusing
150/// anything not addressed to [`matrix_server_name()`] (plan §1: no
151/// federation, so a foreign-server mxid can never be a real local user).
152pub fn public_id_from_mxid(mxid: &str) -> Result<&str, MatrixIdError> {
153    let rest = mxid.strip_prefix('@').ok_or(MatrixIdError::MissingSigil)?;
154    let (localpart, server_name) = rest.split_once(':').ok_or(MatrixIdError::MissingServerName)?;
155    if !is_local_server_name(server_name) {
156        return Err(MatrixIdError::ForeignServerName);
157    }
158    Ok(localpart)
159}
160
161/// Mint 16 random bytes, URL-safe base64, no padding (~22 chars) — the
162/// random component shared by room ids and event ids (plan §1).
163fn random_id_component() -> String {
164    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
165    use base64::Engine;
166    use rand::Rng;
167    let bytes: [u8; 16] = rand::thread_rng().gen();
168    URL_SAFE_NO_PAD.encode(bytes)
169}
170
171/// Mint a fresh room id: `"!" + 22 random URL-safe-base64 chars + ":example.org"`.
172pub fn new_room_id() -> String {
173    format!("!{}:{}", random_id_component(), matrix_server_name())
174}
175
176/// Mint a fresh event id: `"$" + 22 random URL-safe-base64 chars` — opaque,
177/// no reference-hash content addressing (no federation to need it).
178pub fn new_event_id() -> String {
179    format!("${}", random_id_component())
180}
181
182// ============================================================================
183// Errors
184// ============================================================================
185
186/// Why a write into this store was refused, on top of a real database
187/// failure — same shape as `dm_db::MessageError`/`IdentityKeyError`.
188#[derive(Debug)]
189pub enum MatrixStoreError {
190    Db(rusqlite::Error),
191    /// An event's `content` was not valid JSON where this module needed to
192    /// read a field out of it (membership, power levels, `m.relates_to`).
193    Json(serde_json::Error),
194    /// [`ensure_matrix_user`] was asked to mint a reserved localpart (plan
195    /// §10 item 6) — see [`is_reserved_localpart`].
196    ReservedLocalpart,
197    /// A second `m.annotation` relation with the same sender, target, and
198    /// aggregation key as an existing one (plan §2/§9: maps to
199    /// `M_DUPLICATE_ANNOTATION`/400 at the route layer, a later piece).
200    DuplicateAnnotation,
201    /// A referenced event id does not exist in `events`.
202    UnknownEventId(String),
203    /// A referenced mxid does not exist in `matrix_users` — the caller must
204    /// [`ensure_matrix_user`] before naming that user in room state.
205    UnknownMxid(String),
206    /// An `m.room.member` event's `content.membership` was missing or not
207    /// one of `join`/`invite`/`leave`/`ban`.
208    InvalidMembership(String),
209    /// A relation's `m.relates_to` target (the `String` is the target event
210    /// id) does not exist, or exists in a different room than the relation
211    /// event being inserted — a relation can never point out of its own
212    /// room.
213    InvalidRelationTarget(String),
214    /// A referenced event (the `String` is its event id) exists but is in a
215    /// different room than the one the caller named — refused rather than
216    /// silently acted on, since acting on it would let a caller redact or
217    /// mark-read an event that was never a member of the room they claimed.
218    WrongRoom(String),
219    /// [`redact_event`] was asked to redact an event type this server never
220    /// allows to be redacted (the `String` is that `event_type`) — see
221    /// [`redact_event`]'s doc comment.
222    UnredactableEvent(String),
223}
224
225impl From<rusqlite::Error> for MatrixStoreError {
226    fn from(e: rusqlite::Error) -> Self {
227        MatrixStoreError::Db(e)
228    }
229}
230
231impl From<serde_json::Error> for MatrixStoreError {
232    fn from(e: serde_json::Error) -> Self {
233        MatrixStoreError::Json(e)
234    }
235}
236
237// ============================================================================
238// Small TEXT-backed enums (`as_str`/`from_wire_name`, matching
239// `social_db::Class`'s own convention)
240// ============================================================================
241
242/// Our own room-kind label (`rooms.kind`) — drives the power-level/history-
243/// visibility defaults of plan §5. Not a Matrix wire field.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum RoomKind {
246    Dm,
247    Group,
248    Channel,
249}
250
251impl RoomKind {
252    pub fn as_str(self) -> &'static str {
253        match self {
254            RoomKind::Dm => "dm",
255            RoomKind::Group => "group",
256            RoomKind::Channel => "channel",
257        }
258    }
259
260    pub fn from_wire_name(s: &str) -> Option<Self> {
261        match s {
262            "dm" => Some(RoomKind::Dm),
263            "group" => Some(RoomKind::Group),
264            "channel" => Some(RoomKind::Channel),
265            _ => None,
266        }
267    }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum JoinRule {
272    Invite,
273    Public,
274}
275
276impl JoinRule {
277    pub fn as_str(self) -> &'static str {
278        match self {
279            JoinRule::Invite => "invite",
280            JoinRule::Public => "public",
281        }
282    }
283
284    pub fn from_wire_name(s: &str) -> Option<Self> {
285        match s {
286            "invite" => Some(JoinRule::Invite),
287            "public" => Some(JoinRule::Public),
288            _ => None,
289        }
290    }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum HistoryVisibility {
295    Shared,
296    WorldReadable,
297    Invited,
298    Joined,
299}
300
301impl HistoryVisibility {
302    pub fn as_str(self) -> &'static str {
303        match self {
304            HistoryVisibility::Shared => "shared",
305            HistoryVisibility::WorldReadable => "world_readable",
306            HistoryVisibility::Invited => "invited",
307            HistoryVisibility::Joined => "joined",
308        }
309    }
310
311    pub fn from_wire_name(s: &str) -> Option<Self> {
312        match s {
313            "shared" => Some(HistoryVisibility::Shared),
314            "world_readable" => Some(HistoryVisibility::WorldReadable),
315            "invited" => Some(HistoryVisibility::Invited),
316            "joined" => Some(HistoryVisibility::Joined),
317            _ => None,
318        }
319    }
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum Membership {
324    Join,
325    Invite,
326    Leave,
327    Ban,
328}
329
330impl Membership {
331    pub fn as_str(self) -> &'static str {
332        match self {
333            Membership::Join => "join",
334            Membership::Invite => "invite",
335            Membership::Leave => "leave",
336            Membership::Ban => "ban",
337        }
338    }
339
340    pub fn from_wire_name(s: &str) -> Option<Self> {
341        match s {
342            "join" => Some(Membership::Join),
343            "invite" => Some(Membership::Invite),
344            "leave" => Some(Membership::Leave),
345            "ban" => Some(Membership::Ban),
346            _ => None,
347        }
348    }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum ReceiptType {
353    Read,
354    ReadPrivate,
355}
356
357impl ReceiptType {
358    pub fn as_str(self) -> &'static str {
359        match self {
360            ReceiptType::Read => "m.read",
361            ReceiptType::ReadPrivate => "m.read.private",
362        }
363    }
364
365    pub fn from_wire_name(s: &str) -> Option<Self> {
366        match s {
367            "m.read" => Some(ReceiptType::Read),
368            "m.read.private" => Some(ReceiptType::ReadPrivate),
369            _ => None,
370        }
371    }
372}
373
374/// Decode a TEXT column this module itself always writes from one of the
375/// enums above — a `None` from `parse` means the row was written by
376/// something other than this module's own CRUD, which is a data-integrity
377/// bug, not a normal "not found" case, hence `InvalidColumnType` rather
378/// than silently defaulting.
379fn decode_enum<T>(idx: usize, column: &'static str, raw: &str, parse: fn(&str) -> Option<T>) -> rusqlite::Result<T> {
380    parse(raw).ok_or_else(|| rusqlite::Error::InvalidColumnType(idx, column.to_string(), rusqlite::types::Type::Text))
381}
382
383// ============================================================================
384// Schema
385// ============================================================================
386
387/// Create every table/index this module needs — idempotent (`IF NOT
388/// EXISTS`/`INSERT OR IGNORE` throughout), so it runs on every boot. Called
389/// from [`init_messenger_db`] and directly by this module's own tests
390/// against a plain in-memory connection (no SQLCipher key needed for schema
391/// creation) — same split `social_db::create_social_schema` uses.
392///
393/// Deviation from the plan's literal DDL text (§2, both noted inline as
394/// `-- DEVIATION`): `account_data.room_id` drops its `REFERENCES rooms(id)`
395/// — the manager's own correction makes `room_id` `NOT NULL DEFAULT ''` for
396/// global account data, and `''` never matches a real room id, so keeping
397/// the foreign key would make every global upsert fail once `PRAGMA
398/// foreign_keys=ON` is set (as [`init_messenger_db`] does).
399pub fn create_matrix_schema(conn: &Connection) -> rusqlite::Result<()> {
400    conn.execute_batch(
401        r#"
402        -- Global stream ordering — see this module's doc comment on the
403        -- single-writer guarantee that makes `UPDATE ... RETURNING` safe.
404        CREATE TABLE IF NOT EXISTS stream_counter (
405            id    INTEGER PRIMARY KEY CHECK (id = 1),
406            value INTEGER NOT NULL
407        );
408        INSERT OR IGNORE INTO stream_counter (id, value) VALUES (1, 0);
409
410        -- Federation F0: this server's signing keys and the verify keys
411        -- cached from remote servers. Secrets live only in the (encrypted) DB.
412        CREATE TABLE IF NOT EXISTS fed_signing_keys (
413            key_id     TEXT PRIMARY KEY,
414            secret     BLOB NOT NULL,
415            created_ms INTEGER NOT NULL,
416            retired_ms INTEGER
417        );
418        CREATE TABLE IF NOT EXISTS fed_remote_keys (
419            server_name    TEXT NOT NULL,
420            key_id         TEXT NOT NULL,
421            public_key     TEXT NOT NULL,
422            valid_until_ms INTEGER NOT NULL,
423            fetched_ms     INTEGER NOT NULL,
424            PRIMARY KEY (server_name, key_id)
425        );
426
427        -- user_id -> mxid, filled on first touch by ensure_matrix_user
428        -- (plan §2 manager decision: new table, not in the original DDL
429        -- text). public_id is immutable, so this mapping never changes.
430        CREATE TABLE IF NOT EXISTS matrix_users (
431            user_id    INTEGER PRIMARY KEY,
432            mxid       TEXT NOT NULL UNIQUE,
433            created_at TEXT NOT NULL,
434            nick       TEXT
435        );
436        CREATE UNIQUE INDEX IF NOT EXISTS idx_matrix_users_nick_lower
437            ON matrix_users(LOWER(nick)) WHERE nick IS NOT NULL;
438
439        -- Nick belongs to a session, not to matrix_users and not to the
440        -- device. device_id is only a mark. One device may have many sessions.
441        -- matrix_users.nick stays for old databases and is not read.
442        CREATE TABLE IF NOT EXISTS messenger_sessions (
443            session_id TEXT PRIMARY KEY,
444            user_id    INTEGER NOT NULL,
445            device_id  TEXT NOT NULL,
446            nick       TEXT NOT NULL
447        );
448        CREATE INDEX IF NOT EXISTS idx_messenger_sessions_user
449            ON messenger_sessions(user_id);
450        CREATE UNIQUE INDEX IF NOT EXISTS idx_messenger_sessions_nick_lower
451            ON messenger_sessions(LOWER(nick));
452
453        CREATE TABLE IF NOT EXISTS rooms (
454            id                  TEXT PRIMARY KEY,
455            kind                TEXT NOT NULL,
456            room_version        TEXT NOT NULL DEFAULT '11',
457            creator_user_id     INTEGER NOT NULL,
458            created_at          TEXT NOT NULL,
459            is_encrypted        INTEGER NOT NULL DEFAULT 0,
460            join_rule           TEXT NOT NULL DEFAULT 'invite',
461            history_visibility  TEXT NOT NULL DEFAULT 'shared',
462            dm_pair_key         TEXT UNIQUE,
463            legacy_dm_id        INTEGER UNIQUE
464        );
465        CREATE INDEX IF NOT EXISTS idx_rooms_kind ON rooms(kind);
466
467        CREATE TABLE IF NOT EXISTS events (
468            stream_id        INTEGER PRIMARY KEY,
469            event_id         TEXT NOT NULL UNIQUE,
470            room_id          TEXT NOT NULL REFERENCES rooms(id),
471            sender_user_id   INTEGER NOT NULL,
472            event_type       TEXT NOT NULL,
473            state_key        TEXT,
474            content          TEXT NOT NULL,
475            origin_server_ts INTEGER NOT NULL,
476            txn_id           TEXT,
477            redacts          TEXT REFERENCES events(event_id),
478            redacted_by      TEXT REFERENCES events(event_id)
479        );
480        CREATE INDEX IF NOT EXISTS idx_events_room_stream ON events(room_id, stream_id);
481        CREATE INDEX IF NOT EXISTS idx_events_room_type_state ON events(room_id, event_type, state_key);
482        CREATE INDEX IF NOT EXISTS idx_events_sender ON events(sender_user_id, stream_id);
483
484        CREATE TABLE IF NOT EXISTS current_state (
485            room_id    TEXT NOT NULL REFERENCES rooms(id),
486            event_type TEXT NOT NULL,
487            state_key  TEXT NOT NULL,
488            event_id   TEXT NOT NULL REFERENCES events(event_id),
489            PRIMARY KEY (room_id, event_type, state_key)
490        );
491
492        CREATE TABLE IF NOT EXISTS room_members (
493            room_id     TEXT NOT NULL REFERENCES rooms(id),
494            user_id     INTEGER NOT NULL,
495            membership  TEXT NOT NULL,
496            power_level INTEGER,
497            updated_at  TEXT NOT NULL,
498            PRIMARY KEY (room_id, user_id)
499        );
500        CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id, membership);
501
502        CREATE TABLE IF NOT EXISTS relations (
503            event_id  TEXT PRIMARY KEY REFERENCES events(event_id),
504            room_id   TEXT NOT NULL REFERENCES rooms(id),
505            rel_type  TEXT NOT NULL,
506            target_id TEXT NOT NULL REFERENCES events(event_id),
507            agg_key   TEXT
508        );
509        CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_id, rel_type);
510
511        CREATE TABLE IF NOT EXISTS receipts (
512            room_id      TEXT NOT NULL REFERENCES rooms(id),
513            user_id      INTEGER NOT NULL,
514            receipt_type TEXT NOT NULL,
515            event_id     TEXT NOT NULL REFERENCES events(event_id),
516            ts           INTEGER NOT NULL,
517            stream_id    INTEGER NOT NULL,
518            PRIMARY KEY (room_id, user_id, receipt_type)
519        );
520        CREATE INDEX IF NOT EXISTS idx_receipts_room_stream ON receipts(room_id, stream_id);
521
522        -- DEVIATION from plan §2's literal text: room_id has no
523        -- `REFERENCES rooms(id)` — see this function's doc comment.
524        CREATE TABLE IF NOT EXISTS account_data (
525            user_id   INTEGER NOT NULL,
526            room_id   TEXT NOT NULL DEFAULT '',
527            data_type TEXT NOT NULL,
528            content   TEXT NOT NULL,
529            stream_id INTEGER NOT NULL,
530            PRIMARY KEY (user_id, room_id, data_type)
531        );
532        CREATE INDEX IF NOT EXISTS idx_account_data_user_stream ON account_data(user_id, stream_id);
533
534        CREATE TABLE IF NOT EXISTS txn_dedup (
535            user_id    INTEGER NOT NULL,
536            device_id  TEXT NOT NULL,
537            txn_id     TEXT NOT NULL,
538            event_id   TEXT REFERENCES events(event_id),
539            created_at TEXT NOT NULL,
540            PRIMARY KEY (user_id, device_id, txn_id)
541        );
542
543        CREATE TABLE IF NOT EXISTS filters (
544            id         INTEGER PRIMARY KEY AUTOINCREMENT,
545            user_id    INTEGER NOT NULL,
546            definition TEXT NOT NULL
547        );
548
549        -- legacy_dm_message_map removed (M2); drop_legacy_dm_scaffold_if_empty cleans old DBs
550        "#,
551    )?;
552    // Public plaintext store: own tables, created beside (never inside) the closed set.
553    crate::public_channels::create_public_schema(conn)?;
554    crate::public_forum::create_forum_schema(conn)?;
555    crate::media::create_media_schema(conn)?;
556    crate::fed_rooms::create_fed_schema(conn)?;
557    crate::identities::create_identities_schema(conn)
558}
559
560/// Parses the 32-byte database key given as 64 hex characters (the raw SQLCipher key).
561pub fn parse_db_key(key_hex: &str) -> Result<[u8; 32], String> {
562    let h = key_hex.trim();
563    if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
564        return Err("database key must be 64 hex characters (32 bytes)".into());
565    }
566    let mut key = [0u8; 32];
567    for (i, b) in key.iter_mut().enumerate() {
568        *b = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).map_err(|_| "database key must be hex".to_string())?;
569    }
570    Ok(key)
571}
572
573/// The SQLCipher store configuration for `path` and a hex key (see [`parse_db_key`]).
574pub fn messenger_db_config(path: &str, key_hex: &str) -> Result<tesserax_store::DbConfig, String> {
575    let key = parse_db_key(key_hex)?;
576    Ok(tesserax_store::DbConfig::encrypted_native(path, std::sync::Arc::new(tesserax_store::keysource::StaticKeySource(key))))
577}
578
579/// Opens the messenger store (tesserax-store: SQLCipher, WAL, one writer) and makes sure every
580/// table exists. The key is applied first on every connection the engine opens.
581pub fn open_messenger_db(path: &str, key_hex: &str) -> Result<tesserax_store::Db, String> {
582    let cfg = messenger_db_config(path, key_hex)?;
583    let db = tesserax_store::Db::open(&cfg).map_err(|e| e.to_string())?;
584    db.blocking(|conn| ensure_schema(conn)).map_err(|e| e.to_string())?;
585    Ok(db)
586}
587
588/// Parallel read-only connections (same file, same key) for the store opened by [`open_messenger_db`].
589pub fn open_read_pool(path: &str, key_hex: &str, size: usize) -> Result<tesserax_store::ReadPool, String> {
590    let cfg = messenger_db_config(path, key_hex)?;
591    tesserax_store::ReadPoolConfig::from_config(cfg).pool_size(size.max(1)).open().map_err(|e| e.to_string())
592}
593
594/// Creates every messenger table that does not exist yet (idempotent).
595pub fn ensure_schema(conn: &Connection) -> rusqlite::Result<()> {
596    create_matrix_schema(conn)?;
597    crate::keys::create_matrix_keys_schema(conn)?;
598    crate::retention::create_retention_schema(conn)?;
599    crate::public_channels::create_public_schema(conn)?;
600    Ok(())
601}
602
603// ============================================================================
604// Stream ordering
605// ============================================================================
606
607/// Take the next value from the single global counter, inside `tx` — always
608/// called in the SAME transaction as the row it stamps (this module's one
609/// hard invariant; see the module doc's single-writer note on why this
610/// stays race-free with no additional locking). `pub(crate)` so
611/// `matrix_keys_store.rs` can stamp its own stream-ordered tables
612/// (`to_device_messages`, `device_list_changes`) off this same counter
613/// rather than minting a second one.
614pub(crate) fn next_stream_id(tx: &Transaction) -> rusqlite::Result<i64> {
615    tx.query_row("UPDATE stream_counter SET value = value + 1 WHERE id = 1 RETURNING value", [], |row| row.get(0))
616}
617
618/// The last stream id issued to any table — the counter's current value.
619/// Since every stream-ordered table shares this one counter, this is also
620/// the highest `stream_id` that exists anywhere in the database right now.
621pub fn max_stream_id(conn: &Connection) -> rusqlite::Result<i64> {
622    conn.query_row("SELECT value FROM stream_counter WHERE id = 1", [], |row| row.get(0))
623}
624
625// ============================================================================
626// Matrix users (user_id <-> mxid)
627// ============================================================================
628
629/// Register `user_id`'s mxid on first touch (idempotent — a later call for
630/// the same `user_id` is a no-op) and return it. Refuses to mint a
631/// [reserved][is_reserved_localpart] localpart.
632pub fn ensure_matrix_user(conn: &Connection, user_id: i64, public_id: &str, now: &str) -> Result<String, MatrixStoreError> {
633    if is_reserved_localpart(public_id) {
634        return Err(MatrixStoreError::ReservedLocalpart);
635    }
636    let mxid = mxid_for_public_id(public_id);
637    conn.execute(
638        "INSERT INTO matrix_users (user_id, mxid, created_at) VALUES (?1, ?2, ?3)
639         ON CONFLICT(user_id) DO NOTHING",
640        params![user_id, mxid, now],
641    )?;
642    Ok(mxid)
643}
644
645/// `user_id`'s mxid, if [`ensure_matrix_user`] has ever run for it.
646pub fn mxid_of(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<String>> {
647    conn.query_row("SELECT mxid FROM matrix_users WHERE user_id = ?1", params![user_id], |row| row.get(0))
648        .optional()
649}
650
651/// The internal `user_id` behind `mxid`, if known.
652pub fn user_id_of(conn: &Connection, mxid: &str) -> rusqlite::Result<Option<i64>> {
653    conn.query_row("SELECT user_id FROM matrix_users WHERE mxid = ?1", params![mxid], |row| row.get(0))
654        .optional()
655}
656
657// ============================================================================
658// Rooms
659// ============================================================================
660
661#[derive(Debug, Clone, PartialEq)]
662pub struct Room {
663    pub id: String,
664    pub kind: RoomKind,
665    pub room_version: String,
666    pub creator_user_id: i64,
667    pub created_at: String,
668    pub is_encrypted: bool,
669    pub join_rule: JoinRule,
670    pub history_visibility: HistoryVisibility,
671    pub dm_pair_key: Option<String>,
672    pub legacy_dm_id: Option<i64>,
673}
674
675const ROOM_SELECT_COLUMNS: &str =
676    "id, kind, room_version, creator_user_id, created_at, is_encrypted, join_rule, history_visibility, dm_pair_key, legacy_dm_id";
677
678fn room_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Room> {
679    let kind_raw: String = row.get(1)?;
680    let join_rule_raw: String = row.get(6)?;
681    let history_visibility_raw: String = row.get(7)?;
682    Ok(Room {
683        id: row.get(0)?,
684        kind: decode_enum(1, "kind", &kind_raw, RoomKind::from_wire_name)?,
685        room_version: row.get(2)?,
686        creator_user_id: row.get(3)?,
687        created_at: row.get(4)?,
688        is_encrypted: row.get(5)?,
689        join_rule: decode_enum(6, "join_rule", &join_rule_raw, JoinRule::from_wire_name)?,
690        history_visibility: decode_enum(7, "history_visibility", &history_visibility_raw, HistoryVisibility::from_wire_name)?,
691        dm_pair_key: row.get(8)?,
692        legacy_dm_id: row.get(9)?,
693    })
694}
695
696/// The shared core of [`create_room`]/[`create_room_with_state`]: one INSERT
697/// into `rooms`. Takes a plain `&Connection` — callable as `insert_room_row(conn, ...)`
698/// from the standalone [`create_room`] or as `insert_room_row(&tx, ...)` from
699/// inside [`create_room_with_state`]'s transaction (`rusqlite::Transaction`
700/// derefs to `Connection`, so the same function body serves both, per this
701/// module's "single-event API and the batch share code" rule).
702#[allow(clippy::too_many_arguments)]
703fn insert_room_row(
704    conn: &Connection,
705    room_id: &str,
706    kind: RoomKind,
707    creator_user_id: i64,
708    created_at: &str,
709    is_encrypted: bool,
710    join_rule: JoinRule,
711    history_visibility: HistoryVisibility,
712    dm_pair_key: Option<&str>,
713    legacy_dm_id: Option<i64>,
714) -> rusqlite::Result<()> {
715    conn.execute(
716        "INSERT INTO rooms (id, kind, room_version, creator_user_id, created_at, is_encrypted, join_rule, history_visibility, dm_pair_key, legacy_dm_id)
717         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
718        params![
719            room_id,
720            kind.as_str(),
721            MATRIX_ROOM_VERSION,
722            creator_user_id,
723            created_at,
724            is_encrypted,
725            join_rule.as_str(),
726            history_visibility.as_str(),
727            dm_pair_key,
728            legacy_dm_id,
729        ],
730    )?;
731    Ok(())
732}
733
734/// Insert a room's metadata row only — no bootstrap state events (those go
735/// through [`apply_state_event`] separately, e.g. `m.room.create`,
736/// `m.room.member`, `m.room.power_levels`, per plan §5/§7). Kept as a
737/// standalone single-row entry point for a future non-transactional caller
738/// and for this module's own tests; [`create_room_with_state`] is the P5
739/// batch entry point everything under `routes::matrix::rooms` uses instead.
740#[allow(clippy::too_many_arguments)]
741pub fn create_room(
742    conn: &Connection,
743    room_id: &str,
744    kind: RoomKind,
745    creator_user_id: i64,
746    created_at: &str,
747    is_encrypted: bool,
748    join_rule: JoinRule,
749    history_visibility: HistoryVisibility,
750    dm_pair_key: Option<&str>,
751    legacy_dm_id: Option<i64>,
752) -> rusqlite::Result<()> {
753    insert_room_row(conn, room_id, kind, creator_user_id, created_at, is_encrypted, join_rule, history_visibility, dm_pair_key, legacy_dm_id)
754}
755
756pub fn get_room(conn: &Connection, room_id: &str) -> rusqlite::Result<Option<Room>> {
757    conn.query_row(&format!("SELECT {ROOM_SELECT_COLUMNS} FROM rooms WHERE id = ?1"), params![room_id], room_from_row)
758        .optional()
759}
760
761/// The room currently holding `pair_key` as its `dm_pair_key`, if any — the
762/// binary crate's `routes::matrix::rooms::create_room`'s DM-reuse lookup
763/// (plan P5 correction: "a second DM between the same pair returns the
764/// existing room id only if it is still a live DM for both").
765pub fn room_by_dm_pair_key(conn: &Connection, pair_key: &str) -> rusqlite::Result<Option<Room>> {
766    conn.query_row(&format!("SELECT {ROOM_SELECT_COLUMNS} FROM rooms WHERE dm_pair_key = ?1"), params![pair_key], room_from_row)
767        .optional()
768}
769
770/// The room already migrated from `dm_conversations.id = legacy_dm_id`, if
771/// any — the binary crate's own `matrix_migration` module's idempotency gate
772/// (plan §7 step 2: "For each `dm_conversations` row without a
773/// `rooms.legacy_dm_id` match").
774pub fn room_by_legacy_dm_id(conn: &Connection, legacy_dm_id: i64) -> rusqlite::Result<Option<Room>> {
775    conn.query_row(&format!("SELECT {ROOM_SELECT_COLUMNS} FROM rooms WHERE legacy_dm_id = ?1"), params![legacy_dm_id], room_from_row)
776        .optional()
777}
778
779/// Free `room_id`'s `dm_pair_key` slot (set it to `NULL`) — called when a
780/// prior DM between the same pair is no longer live (one party left), so a
781/// freshly created room between the same two users can claim that pair key
782/// without violating `rooms.dm_pair_key`'s `UNIQUE` constraint. The old room
783/// keeps every other field; only its claim on the pair key is released.
784pub fn clear_dm_pair_key(conn: &Connection, room_id: &str) -> rusqlite::Result<()> {
785    conn.execute("UPDATE rooms SET dm_pair_key = NULL WHERE id = ?1", params![room_id])?;
786    Ok(())
787}
788
789// ============================================================================
790// Events (timeline + state)
791// ============================================================================
792
793#[derive(Debug, Clone, PartialEq)]
794pub struct MatrixEvent {
795    pub stream_id: i64,
796    pub event_id: String,
797    pub room_id: String,
798    pub sender_user_id: i64,
799    pub event_type: String,
800    /// `None` for a timeline event; `Some("")` or more for a state event.
801    pub state_key: Option<String>,
802    pub content: String,
803    pub origin_server_ts: i64,
804    pub txn_id: Option<String>,
805    pub redacts: Option<String>,
806    pub redacted_by: Option<String>,
807}
808
809const EVENT_SELECT_COLUMNS: &str =
810    "stream_id, event_id, room_id, sender_user_id, event_type, state_key, content, origin_server_ts, txn_id, redacts, redacted_by";
811
812const EVENT_SELECT_COLUMNS_ALIASED: &str = "e.stream_id, e.event_id, e.room_id, e.sender_user_id, e.event_type, e.state_key, e.content, e.origin_server_ts, e.txn_id, e.redacts, e.redacted_by";
813
814fn event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MatrixEvent> {
815    Ok(MatrixEvent {
816        stream_id: row.get(0)?,
817        event_id: row.get(1)?,
818        room_id: row.get(2)?,
819        sender_user_id: row.get(3)?,
820        event_type: row.get(4)?,
821        state_key: row.get(5)?,
822        content: row.get(6)?,
823        origin_server_ts: row.get(7)?,
824        txn_id: row.get(8)?,
825        redacts: row.get(9)?,
826        redacted_by: row.get(10)?,
827    })
828}
829
830fn collect_events(rows: &mut rusqlite::Rows<'_>) -> rusqlite::Result<Vec<MatrixEvent>> {
831    let mut out = Vec::new();
832    while let Some(row) = rows.next()? {
833        out.push(event_from_row(row)?);
834    }
835    Ok(out)
836}
837
838/// The columns of one timeline event row, grouped for
839/// [`insert_timeline_event_in_tx`] (its callers all build one; the public
840/// writers spell the same fields out as separate arguments).
841#[derive(Debug, Clone, Copy)]
842struct TimelineEventRow<'a> {
843    event_id: &'a str,
844    room_id: &'a str,
845    sender_user_id: i64,
846    event_type: &'a str,
847    content: &'a str,
848    origin_server_ts: i64,
849    txn_id: Option<&'a str>,
850}
851
852/// The `&Transaction`-scoped core of [`insert_timeline_event`]. Room
853/// creation (`createRoom`) never sends a message, so [`create_room_with_state`]
854/// has no timeline events to insert and this function's only caller today is
855/// [`insert_timeline_event`] itself — it is split out anyway, matching
856/// [`apply_state_event_in_tx`]'s shape, so a future batch that DOES need to
857/// mix timeline and state events (e.g. the P12 migration's legacy-message
858/// replay) can call it directly instead of re-deriving a transaction-scoped
859/// version of this logic.
860fn insert_timeline_event_in_tx(tx: &Transaction, row: &TimelineEventRow<'_>) -> Result<MatrixEvent, MatrixStoreError> {
861    let TimelineEventRow { event_id, room_id, sender_user_id, event_type, content, origin_server_ts, txn_id } = *row;
862    let stream_id = next_stream_id(tx)?;
863    tx.execute(
864        "INSERT INTO events (stream_id, event_id, room_id, sender_user_id, event_type, state_key, content, origin_server_ts, txn_id)
865         VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8)",
866        params![stream_id, event_id, room_id, sender_user_id, event_type, content, origin_server_ts, txn_id],
867    )?;
868    populate_relations(tx, event_id, room_id, sender_user_id, content)?;
869    Ok(MatrixEvent {
870        stream_id,
871        event_id: event_id.to_string(),
872        room_id: room_id.to_string(),
873        sender_user_id,
874        event_type: event_type.to_string(),
875        state_key: None,
876        content: content.to_string(),
877        origin_server_ts,
878        txn_id: txn_id.map(str::to_string),
879        redacts: None,
880        redacted_by: None,
881    })
882}
883
884/// Insert one timeline event (`state_key` always `NULL`) inside its own
885/// transaction: mint the next stream id, insert the row, and — if
886/// `content` carries a top-level `m.relates_to` (cleartext even for
887/// `m.room.encrypted`, plan §10 item 3) — populate [`relations`]. Refusing
888/// a duplicate `m.annotation` rolls back the whole transaction, so a
889/// refused call leaves no partial `events` row. It never records a `txn_id`:
890/// a write that carries one goes through [`insert_timeline_event_deduped`],
891/// which stores it next to its dedup record in the same transaction.
892pub fn insert_timeline_event(
893    conn: &mut Connection,
894    event_id: &str,
895    room_id: &str,
896    sender_user_id: i64,
897    event_type: &str,
898    content: &str,
899    origin_server_ts: i64,
900) -> Result<MatrixEvent, MatrixStoreError> {
901    let tx = conn.transaction()?;
902    let row = TimelineEventRow { event_id, room_id, sender_user_id, event_type, content, origin_server_ts, txn_id: None };
903    let event = insert_timeline_event_in_tx(&tx, &row)?;
904    tx.commit()?;
905    Ok(event)
906}
907
908/// What [`insert_timeline_event_deduped`]/[`redact_event_deduped`] found: a
909/// brand-new write, or the SAME event a prior submission of this
910/// `(user, device, txn_id)` already produced (plan §4 `send`/`redact` rows:
911/// "idempotent per (user, device, txn_id): a repeat returns the SAME
912/// event_id with no second insert").
913#[derive(Debug, Clone, PartialEq)]
914pub enum DedupedWrite {
915    New(MatrixEvent),
916    Existing(MatrixEvent),
917}
918
919/// [`insert_timeline_event`], but dedup-checked and recorded in the SAME
920/// transaction as the insert (P6 binding rule) — a repeat of
921/// `(sender_user_id, device_id, txn_id)` returns the ORIGINAL event without a
922/// second `events` row, and this is race-free even under a hypothetical
923/// second writer (never actually possible under this module's single-writer
924/// discipline) because the lookup, the insert, and the dedup record all
925/// commit or roll back together. `txn_dedup_lookup`/`txn_dedup_record` take
926/// `&Connection`; passing `&tx` (a `Transaction`) works via `Deref` — see
927/// `apply_state_event_in_tx`'s own sibling functions for the same pattern.
928#[allow(clippy::too_many_arguments)]
929pub fn insert_timeline_event_deduped(
930    conn: &mut Connection,
931    device_id: &str,
932    txn_id: &str,
933    event_id: &str,
934    room_id: &str,
935    sender_user_id: i64,
936    event_type: &str,
937    content: &str,
938    origin_server_ts: i64,
939    now: &str,
940) -> Result<DedupedWrite, MatrixStoreError> {
941    let tx = conn.transaction()?;
942    if let TxnDedupEntry::Seen(existing_event_id) = txn_dedup_lookup(&tx, sender_user_id, device_id, txn_id)? {
943        let existing_event_id = existing_event_id.ok_or_else(|| MatrixStoreError::UnknownEventId(txn_id.to_string()))?;
944        let event = get_event(&tx, &existing_event_id)?.ok_or_else(|| MatrixStoreError::UnknownEventId(existing_event_id.clone()))?;
945        tx.commit()?;
946        return Ok(DedupedWrite::Existing(event));
947    }
948    let row = TimelineEventRow { event_id, room_id, sender_user_id, event_type, content, origin_server_ts, txn_id: Some(txn_id) };
949    let event = insert_timeline_event_in_tx(&tx, &row)?;
950    txn_dedup_record(&tx, sender_user_id, device_id, txn_id, Some(event_id), now)?;
951    tx.commit()?;
952    Ok(DedupedWrite::New(event))
953}
954
955/// The shared core of [`apply_state_event`] and the P5 batch entry point
956/// [`create_room_with_state`] — every bootstrap state event a room creation
957/// inserts (`m.room.create`, both `m.room.member`s, `m.room.power_levels`,
958/// ...) goes through this SAME function, once per event, all inside the
959/// batch's one transaction, so a failure on any one of them rolls back the
960/// entire room creation (plan P5 correction: "Room creation is ONE
961/// transaction").
962#[allow(clippy::too_many_arguments)]
963fn apply_state_event_in_tx(
964    tx: &Transaction,
965    event_id: &str,
966    room_id: &str,
967    sender_user_id: i64,
968    event_type: &str,
969    state_key: &str,
970    content: &str,
971    origin_server_ts: i64,
972    now: &str,
973) -> Result<MatrixEvent, MatrixStoreError> {
974    let stream_id = next_stream_id(tx)?;
975    tx.execute(
976        "INSERT INTO events (stream_id, event_id, room_id, sender_user_id, event_type, state_key, content, origin_server_ts, txn_id)
977         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL)",
978        params![stream_id, event_id, room_id, sender_user_id, event_type, state_key, content, origin_server_ts],
979    )?;
980    tx.execute(
981        "INSERT INTO current_state (room_id, event_type, state_key, event_id) VALUES (?1, ?2, ?3, ?4)
982         ON CONFLICT(room_id, event_type, state_key) DO UPDATE SET event_id = excluded.event_id",
983        params![room_id, event_type, state_key, event_id],
984    )?;
985    if event_type == "m.room.member" {
986        refresh_room_member(tx, room_id, state_key, content, now)?;
987    }
988    if event_type == "m.room.power_levels" {
989        refresh_power_levels(tx, room_id, content)?;
990    }
991    Ok(MatrixEvent {
992        stream_id,
993        event_id: event_id.to_string(),
994        room_id: room_id.to_string(),
995        sender_user_id,
996        event_type: event_type.to_string(),
997        state_key: Some(state_key.to_string()),
998        content: content.to_string(),
999        origin_server_ts,
1000        txn_id: None,
1001        redacts: None,
1002        redacted_by: None,
1003    })
1004}
1005
1006/// One state event for [`apply_state_event`]: its identity, the state slot
1007/// it fills, its content, and the two clocks (`origin_server_ts` in
1008/// milliseconds for the event row, `now` as RFC 3339 for the membership
1009/// cache's `updated_at`).
1010#[derive(Debug, Clone, Copy)]
1011pub struct StateEventWrite<'a> {
1012    pub event_id: &'a str,
1013    pub room_id: &'a str,
1014    pub sender_user_id: i64,
1015    pub event_type: &'a str,
1016    pub state_key: &'a str,
1017    pub content: &'a str,
1018    pub origin_server_ts: i64,
1019    pub now: &'a str,
1020}
1021
1022/// Insert one state event and refresh the two projections that read off it:
1023/// `current_state` (always) and, for `m.room.member`/`m.room.power_levels`,
1024/// the denormalized `room_members` cache (plan §2). All inside one
1025/// transaction with the event row.
1026pub fn apply_state_event(conn: &mut Connection, write: &StateEventWrite<'_>) -> Result<MatrixEvent, MatrixStoreError> {
1027    let tx = conn.transaction()?;
1028    let event = apply_state_event_in_tx(
1029        &tx,
1030        write.event_id,
1031        write.room_id,
1032        write.sender_user_id,
1033        write.event_type,
1034        write.state_key,
1035        write.content,
1036        write.origin_server_ts,
1037        write.now,
1038    )?;
1039    tx.commit()?;
1040    Ok(event)
1041}
1042
1043/// What one [`refresh_member_displayname`] pass did.
1044#[derive(Debug, Clone, Default, PartialEq, Eq)]
1045pub struct DisplaynameRefresh {
1046    /// How many rooms got a new `m.room.member` event for the user.
1047    pub rooms_updated: usize,
1048    /// Every `user_id` that must be woken for those new events: each updated
1049    /// room's joined and invited members, the refreshed user included.
1050    pub affected_user_ids: HashSet<i64>,
1051}
1052
1053/// Re-stamp `displayname` into `user_id`'s own `m.room.member` event in every
1054/// room where they are currently `join`ed or `invite`d, so the other
1055/// members' clients see the new name. A room whose current member event
1056/// already carries exactly `displayname` is skipped (the pass is idempotent,
1057/// which is what lets the boot backfill call it for every user on every
1058/// start). The new event keeps the current content (an invite keeps its
1059/// `is_direct` marker) and only replaces `displayname`; a `join` refresh is
1060/// sent by the user, an `invite` refresh keeps the inviter as its sender —
1061/// [`stripped_invite_state`] reads the inviter off that field. `leave`/`ban`
1062/// rows are never touched.
1063///
1064/// A user with no [`matrix_users`] row (never used the messenger) or an empty
1065/// `displayname` is a no-op. All rooms land in ONE transaction: a failure
1066/// leaves every room exactly as it was. The label itself comes from the
1067/// identity database, which this module never reads — the caller resolves it
1068/// BEFORE taking the messenger connection (lock order `db` -> `social` ->
1069/// `messenger`).
1070pub fn refresh_member_displayname(
1071    conn: &mut Connection,
1072    user_id: i64,
1073    displayname: &str,
1074    now: &str,
1075    origin_server_ts: i64,
1076) -> Result<DisplaynameRefresh, MatrixStoreError> {
1077    let mut outcome = DisplaynameRefresh::default();
1078    if displayname.is_empty() {
1079        return Ok(outcome);
1080    }
1081    let Some(mxid) = mxid_of(conn, user_id)? else {
1082        return Ok(outcome);
1083    };
1084
1085    let tx = conn.transaction()?;
1086    for membership in [Membership::Join, Membership::Invite] {
1087        for room_id in rooms_for_user(&tx, user_id, Some(membership))? {
1088            let Some(current) = current_state_event(&tx, &room_id, "m.room.member", &mxid)? else {
1089                continue;
1090            };
1091            let mut content: serde_json::Value = serde_json::from_str(&current.content)?;
1092            if content.get("displayname").and_then(|v| v.as_str()) == Some(displayname) {
1093                continue;
1094            }
1095            let Some(fields) = content.as_object_mut() else {
1096                continue;
1097            };
1098            fields.insert("displayname".to_string(), serde_json::Value::String(displayname.to_string()));
1099
1100            let sender_user_id = if membership == Membership::Join { user_id } else { current.sender_user_id };
1101            apply_state_event_in_tx(
1102                &tx,
1103                &new_event_id(),
1104                &room_id,
1105                sender_user_id,
1106                "m.room.member",
1107                &mxid,
1108                &content.to_string(),
1109                origin_server_ts,
1110                now,
1111            )?;
1112            outcome.rooms_updated += 1;
1113            outcome.affected_user_ids.insert(user_id);
1114            for member in room_members(&tx, &room_id, None)? {
1115                if matches!(member.membership, Membership::Join | Membership::Invite) {
1116                    outcome.affected_user_ids.insert(member.user_id);
1117                }
1118            }
1119        }
1120    }
1121    tx.commit()?;
1122    Ok(outcome)
1123}
1124
1125/// Every `user_id` that has a [`matrix_users`] row — the boot backfill's
1126/// worklist of users whose member events may predate the `displayname`
1127/// stamping.
1128pub fn matrix_user_ids(conn: &Connection) -> rusqlite::Result<Vec<i64>> {
1129    let mut stmt = conn.prepare("SELECT user_id FROM matrix_users ORDER BY user_id")?;
1130    let rows = stmt.query_map([], |row| row.get(0))?;
1131    rows.collect()
1132}
1133
1134// ============================================================================
1135// Room creation batch (plan P5 correction: room + every bootstrap state
1136// event, in ONE transaction)
1137// ============================================================================
1138
1139/// One state event queued for [`create_room_with_state`] — the same shape
1140/// [`apply_state_event`] takes per-field, minus `room_id`/`origin_server_ts`/
1141/// `now` (shared by the whole batch, passed once to
1142/// [`create_room_with_state`] itself rather than repeated per event).
1143#[derive(Debug, Clone, PartialEq)]
1144pub struct NewStateEvent {
1145    pub event_id: String,
1146    pub sender_user_id: i64,
1147    pub event_type: String,
1148    pub state_key: String,
1149    pub content: String,
1150}
1151
1152/// The room-metadata half of [`create_room_with_state`]'s input — every
1153/// field [`insert_room_row`] needs, grouped into one value instead of nine
1154/// positional arguments (this function's own params stay lint-clean without
1155/// an `#[allow(clippy::too_many_arguments)]`, unlike the legacy single-row
1156/// [`create_room`]/[`insert_room_row`] this batch shares its INSERT with).
1157#[derive(Debug, Clone, Copy)]
1158pub struct RoomBootstrap<'a> {
1159    pub room_id: &'a str,
1160    pub kind: RoomKind,
1161    pub creator_user_id: i64,
1162    pub created_at: &'a str,
1163    pub is_encrypted: bool,
1164    pub join_rule: JoinRule,
1165    pub history_visibility: HistoryVisibility,
1166    pub dm_pair_key: Option<&'a str>,
1167    pub legacy_dm_id: Option<i64>,
1168}
1169
1170/// Create a room AND its full bootstrap event set in ONE transaction (plan
1171/// P5 correction) — the room row ([`insert_room_row`]), then every entry of
1172/// `state_events` in order via [`apply_state_event_in_tx`] (so, e.g., the
1173/// creator's own `m.room.member` MUST precede `m.room.power_levels` in
1174/// `state_events` for [`refresh_power_levels`] to see them as an existing
1175/// member to stamp — see that function's own doc comment), then commit. A
1176/// failure on the room insert OR on any one state event leaves NEITHER a
1177/// room row NOR any event row behind (rusqlite rolls back a `Transaction`
1178/// dropped without `commit()`, the same guarantee
1179/// [`populate_relations`]'s duplicate-annotation refusal already relies on).
1180pub fn create_room_with_state(
1181    conn: &mut Connection,
1182    bootstrap: RoomBootstrap<'_>,
1183    state_events: &[NewStateEvent],
1184    origin_server_ts: i64,
1185) -> Result<(Room, Vec<MatrixEvent>), MatrixStoreError> {
1186    let tx = conn.transaction()?;
1187    insert_room_row(
1188        &tx,
1189        bootstrap.room_id,
1190        bootstrap.kind,
1191        bootstrap.creator_user_id,
1192        bootstrap.created_at,
1193        bootstrap.is_encrypted,
1194        bootstrap.join_rule,
1195        bootstrap.history_visibility,
1196        bootstrap.dm_pair_key,
1197        bootstrap.legacy_dm_id,
1198    )?;
1199
1200    let mut applied = Vec::with_capacity(state_events.len());
1201    for event in state_events {
1202        applied.push(apply_state_event_in_tx(
1203            &tx,
1204            &event.event_id,
1205            bootstrap.room_id,
1206            event.sender_user_id,
1207            &event.event_type,
1208            &event.state_key,
1209            &event.content,
1210            origin_server_ts,
1211            bootstrap.created_at,
1212        )?);
1213    }
1214    tx.commit()?;
1215
1216    Ok((
1217        Room {
1218            id: bootstrap.room_id.to_string(),
1219            kind: bootstrap.kind,
1220            room_version: MATRIX_ROOM_VERSION.to_string(),
1221            creator_user_id: bootstrap.creator_user_id,
1222            created_at: bootstrap.created_at.to_string(),
1223            is_encrypted: bootstrap.is_encrypted,
1224            join_rule: bootstrap.join_rule,
1225            history_visibility: bootstrap.history_visibility,
1226            dm_pair_key: bootstrap.dm_pair_key.map(str::to_string),
1227            legacy_dm_id: bootstrap.legacy_dm_id,
1228        },
1229        applied,
1230    ))
1231}
1232
1233/// Refresh `room_members` off an `m.room.member` state event. `state_key`
1234/// is the member's mxid (Matrix's own wire convention) — resolved to our
1235/// internal `user_id` via [`user_id_of`]; the member must already have been
1236/// through [`ensure_matrix_user`] or this refuses with
1237/// [`MatrixStoreError::UnknownMxid`]. `power_level` is left untouched on an
1238/// existing row (only [`refresh_power_levels`] ever sets it) and starts
1239/// `NULL` (falls back to `users_default`) on a brand-new one.
1240fn refresh_room_member(tx: &Transaction, room_id: &str, state_key: &str, content: &str, now: &str) -> Result<(), MatrixStoreError> {
1241    let value: serde_json::Value = serde_json::from_str(content)?;
1242    let membership_str = value
1243        .get("membership")
1244        .and_then(|v| v.as_str())
1245        .ok_or_else(|| MatrixStoreError::InvalidMembership("missing 'membership' field".to_string()))?;
1246    let membership =
1247        Membership::from_wire_name(membership_str).ok_or_else(|| MatrixStoreError::InvalidMembership(membership_str.to_string()))?;
1248    let user_id = user_id_of(tx, state_key)?.ok_or_else(|| MatrixStoreError::UnknownMxid(state_key.to_string()))?;
1249    tx.execute(
1250        "INSERT INTO room_members (room_id, user_id, membership, power_level, updated_at)
1251         VALUES (?1, ?2, ?3, NULL, ?4)
1252         ON CONFLICT(room_id, user_id) DO UPDATE SET membership = excluded.membership, updated_at = excluded.updated_at",
1253        params![room_id, user_id, membership.as_str(), now],
1254    )?;
1255    Ok(())
1256}
1257
1258/// Refresh `room_members.power_level` off an `m.room.power_levels` state
1259/// event's `users` map: every current member is reset to `NULL` (falls
1260/// back to `users_default`), then every `users` entry that resolves to a
1261/// known member is applied. An entry naming a user who is not (yet) a
1262/// member is silently skipped — their level will apply once they join,
1263/// resolved fresh from the room's current `m.room.power_levels` by whatever
1264/// reads it (a later piece; this table is a cache of the last-seen event,
1265/// not re-derived from an entry that never had a member row to land on).
1266fn refresh_power_levels(tx: &Transaction, room_id: &str, content: &str) -> Result<(), MatrixStoreError> {
1267    let value: serde_json::Value = serde_json::from_str(content)?;
1268    tx.execute("UPDATE room_members SET power_level = NULL WHERE room_id = ?1", params![room_id])?;
1269    if let Some(users) = value.get("users").and_then(|v| v.as_object()) {
1270        for (mxid, level) in users {
1271            let Some(level) = level.as_i64() else { continue };
1272            let Some(user_id) = user_id_of(tx, mxid)? else { continue };
1273            tx.execute(
1274                "UPDATE room_members SET power_level = ?1 WHERE room_id = ?2 AND user_id = ?3",
1275                params![level, room_id, user_id],
1276            )?;
1277        }
1278    }
1279    Ok(())
1280}
1281
1282// ============================================================================
1283// Power levels (m.room.power_levels content) — plan §5, used by
1284// `routes::matrix::auth`'s PowerCheck gate. Pure `serde_json::Value` logic,
1285// no identity/messenger connection needed, hence living here (a lib-crate
1286// module with no bin-crate dependency) rather than in the binary crate's own
1287// `routes/matrix/auth.rs` alongside the rest of that module's caller/device
1288// resolution.
1289// ============================================================================
1290
1291/// An action `m.room.power_levels` gates by a named threshold field, plus
1292/// `StateDefault` for "may send an otherwise-unlisted state event type".
1293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1294pub enum PowerAction {
1295    Invite,
1296    Kick,
1297    Ban,
1298    Redact,
1299    StateDefault,
1300}
1301
1302impl PowerAction {
1303    fn field_and_default(self) -> (&'static str, i64) {
1304        match self {
1305            PowerAction::Invite => ("invite", 50),
1306            PowerAction::Kick => ("kick", 50),
1307            PowerAction::Ban => ("ban", 50),
1308            PowerAction::Redact => ("redact", 50),
1309            PowerAction::StateDefault => ("state_default", 50),
1310        }
1311    }
1312}
1313
1314/// `power_levels.users[mxid]`, falling back to `users_default` (Matrix
1315/// default `0`) when either field is missing.
1316pub fn user_level(power_levels: &serde_json::Value, mxid: &str) -> i64 {
1317    power_levels
1318        .get("users")
1319        .and_then(|users| users.get(mxid))
1320        .and_then(serde_json::Value::as_i64)
1321        .unwrap_or_else(|| power_levels.get("users_default").and_then(serde_json::Value::as_i64).unwrap_or(0))
1322}
1323
1324/// The power level required to send an event of `event_type`:
1325/// `power_levels.events[event_type]` if present, else `state_default`
1326/// (Matrix default `50`) for a state event or `events_default` (Matrix
1327/// default `0`) for a timeline event.
1328pub fn event_level(power_levels: &serde_json::Value, event_type: &str, is_state: bool) -> i64 {
1329    if let Some(level) = power_levels.get("events").and_then(|events| events.get(event_type)).and_then(serde_json::Value::as_i64) {
1330        return level;
1331    }
1332    let (key, default) = if is_state { ("state_default", 50) } else { ("events_default", 0) };
1333    power_levels.get(key).and_then(serde_json::Value::as_i64).unwrap_or(default)
1334}
1335
1336/// Whether `mxid`'s [`user_level`] reaches the threshold `action` names
1337/// (falling back to the Matrix default when the corresponding field is
1338/// missing — see [`PowerAction::field_and_default`]).
1339pub fn can(power_levels: &serde_json::Value, action: PowerAction, mxid: &str) -> bool {
1340    let (field, default) = action.field_and_default();
1341    let required = power_levels.get(field).and_then(serde_json::Value::as_i64).unwrap_or(default);
1342    user_level(power_levels, mxid) >= required
1343}
1344
1345/// Matrix's room-v11 membership-change authorization rule for acting on
1346/// ANOTHER user (manager review, 2026-09-24 — a real gap in the first cut of
1347/// [`can`] alone): reaching the flat `action` threshold via [`can`] is
1348/// necessary but not sufficient — the sender's own level must ALSO be
1349/// STRICTLY GREATER than the target's current level, so a level-50 admin
1350/// can never kick/ban/unban a level-100 owner or an equal-level peer, even
1351/// though 50 reaches the flat kick/ban threshold. `self_leave` is the one
1352/// exception the spec carves out: a member may always change their OWN
1353/// membership to `leave` (declining an invite, or a self-kick, is just a
1354/// leave) regardless of level — pass `true` only when `new_membership` for
1355/// this call is `leave` and let the identity check below decide whether it
1356/// actually applies.
1357pub fn can_act_on(power_levels: &serde_json::Value, action: PowerAction, sender_mxid: &str, target_mxid: &str, self_leave: bool) -> bool {
1358    if self_leave && sender_mxid == target_mxid {
1359        return true;
1360    }
1361    can(power_levels, action, sender_mxid) && user_level(power_levels, sender_mxid) > user_level(power_levels, target_mxid)
1362}
1363
1364/// The default an `m.room.power_levels` scalar field resolves to when
1365/// absent — `50` for every threshold field, `0` for the two `*_default`
1366/// fields (matches [`PowerAction::field_and_default`] for the fields that
1367/// enum names, plus the two it does not).
1368fn power_levels_scalar_default(key: &str) -> i64 {
1369    match key {
1370        "events_default" | "users_default" => 0,
1371        _ => 50,
1372    }
1373}
1374
1375/// Every top-level scalar field of `m.room.power_levels` this server
1376/// enforces a change rule on (manager review, 2026-09-24). `events[type]`
1377/// entries and `notifications.room` are handled separately below (they are
1378/// nested, not top-level scalars).
1379const POWER_LEVELS_SCALAR_KEYS: [&str; 7] = ["ban", "kick", "redact", "invite", "state_default", "events_default", "users_default"];
1380
1381/// Reject a scalar field change whose OLD or NEW value exceeds `sender_level`
1382/// — shared by every scalar/nested-scalar field [`validate_power_levels_change`]
1383/// checks (the top-level keys, each `events[type]` entry, and
1384/// `notifications.room` all follow the identical "both sides must be ≤ your
1385/// own level" rule; only `users` has the different, asymmetric rule).
1386fn reject_if_either_side_exceeds(old_value: Option<i64>, new_value: Option<i64>, sender_level: i64) -> Result<(), &'static str> {
1387    if old_value != new_value && (old_value.is_some_and(|v| v > sender_level) || new_value.is_some_and(|v| v > sender_level)) {
1388        return Err("cannot change a power-level field at or above your own level");
1389    }
1390    Ok(())
1391}
1392
1393/// The `m.room.power_levels` v11 change-authorization rules beyond the flat
1394/// `PUT state` PowerCheck already gating who may send this event type at
1395/// all (manager review, 2026-09-24 — a real gap: without this, any admin
1396/// who merely reaches `state_default` could PUT themselves straight to
1397/// `100`). `sender_mxid`'s level is read from `old` (their authority BEFORE
1398/// this change takes effect):
1399///
1400/// - Every top-level scalar field ([`POWER_LEVELS_SCALAR_KEYS`]) that
1401///   CHANGES: both the old and the new (Matrix-default-resolved) value must
1402///   be at or below the sender's level.
1403/// - Every `events[type]` entry and `notifications.room` that CHANGES:
1404///   same rule, but with NO invented default for a side where the key is
1405///   simply absent (an absent side is not compared against the sender's
1406///   level at all — only a side that is actually PRESENT and exceeds it
1407///   is refused).
1408/// - `users`: for every mxid whose EFFECTIVE level ([`user_level`], which
1409///   already falls back to `users_default`) differs between `old` and
1410///   `new`: a target OTHER than the sender may never be touched if their
1411///   CURRENT (old) level is already at or above the sender's own level
1412///   (even to lower them) — a peer can never act on an equal-or-higher
1413///   peer; AND no entry's NEW level may exceed the sender's own level
1414///   (the sender may lower their own level, but never raise it, and can
1415///   never promote anyone above themselves).
1416pub fn validate_power_levels_change(old: &serde_json::Value, new: &serde_json::Value, sender_mxid: &str) -> Result<(), &'static str> {
1417    let sender_level = user_level(old, sender_mxid);
1418
1419    for key in POWER_LEVELS_SCALAR_KEYS {
1420        let default = power_levels_scalar_default(key);
1421        let old_value = old.get(key).and_then(serde_json::Value::as_i64).unwrap_or(default);
1422        let new_value = new.get(key).and_then(serde_json::Value::as_i64).unwrap_or(default);
1423        reject_if_either_side_exceeds(Some(old_value), Some(new_value), sender_level)?;
1424    }
1425
1426    let old_events = old.get("events").and_then(serde_json::Value::as_object);
1427    let new_events = new.get("events").and_then(serde_json::Value::as_object);
1428    let mut event_type_keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1429    if let Some(map) = old_events {
1430        event_type_keys.extend(map.keys().map(String::as_str));
1431    }
1432    if let Some(map) = new_events {
1433        event_type_keys.extend(map.keys().map(String::as_str));
1434    }
1435    for event_type in event_type_keys {
1436        let old_value = old_events.and_then(|m| m.get(event_type)).and_then(serde_json::Value::as_i64);
1437        let new_value = new_events.and_then(|m| m.get(event_type)).and_then(serde_json::Value::as_i64);
1438        reject_if_either_side_exceeds(old_value, new_value, sender_level)?;
1439    }
1440
1441    let old_notif_room = old.get("notifications").and_then(|v| v.get("room")).and_then(serde_json::Value::as_i64);
1442    let new_notif_room = new.get("notifications").and_then(|v| v.get("room")).and_then(serde_json::Value::as_i64);
1443    reject_if_either_side_exceeds(old_notif_room, new_notif_room, sender_level)?;
1444
1445    let old_users = old.get("users").and_then(serde_json::Value::as_object);
1446    let new_users = new.get("users").and_then(serde_json::Value::as_object);
1447    let mut user_keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1448    if let Some(map) = old_users {
1449        user_keys.extend(map.keys().map(String::as_str));
1450    }
1451    if let Some(map) = new_users {
1452        user_keys.extend(map.keys().map(String::as_str));
1453    }
1454    for target_mxid in user_keys {
1455        let old_effective = user_level(old, target_mxid);
1456        let new_effective = user_level(new, target_mxid);
1457        if old_effective == new_effective {
1458            continue;
1459        }
1460        if target_mxid != sender_mxid && old_effective >= sender_level {
1461            return Err("cannot change the level of a user at or above your own level");
1462        }
1463        if new_effective > sender_level {
1464            return Err("cannot set a user's level above your own");
1465        }
1466    }
1467
1468    Ok(())
1469}
1470
1471// ============================================================================
1472// Stripped invite-room-state (plan P5 correction: `unsigned.invite_room_state`
1473// on an invite's own `m.room.member` event, computed at read/serialize time —
1474// see this function's own doc for why, and `routes::matrix::client_event_json`
1475// (mod.rs) for its one call site today).
1476// ============================================================================
1477
1478/// One `{content, state_key, type, sender}` Matrix `StrippedStateEvent` —
1479/// `sender` already resolved to an mxid, `content` already parsed JSON.
1480/// `pub` (beyond this module's own [`stripped_invite_state`]) for the
1481/// binary crate's `routes::matrix::sync` module: `GET /sync`'s
1482/// `invite_state.events` needs the SAME stripped shape for the invitee's
1483/// OWN `m.room.member` event, which [`stripped_invite_state`] does not
1484/// itself include (see that function's own doc — it strips the INVITER's
1485/// state, not the invitee's own invite event, since its other call site,
1486/// `routes::matrix::client_event_json`'s `unsigned.invite_room_state`, is
1487/// already attaching it TO that very event).
1488pub fn stripped_state_json(conn: &Connection, event: &MatrixEvent) -> Result<serde_json::Value, MatrixStoreError> {
1489    let sender = mxid_of(conn, event.sender_user_id)?.unwrap_or_default();
1490    let content: serde_json::Value = serde_json::from_str(&event.content)?;
1491    Ok(serde_json::json!({
1492        "content": content,
1493        "state_key": event.state_key.clone().unwrap_or_default(),
1494        "type": event.event_type,
1495        "sender": sender,
1496    }))
1497}
1498
1499/// The stripped state Matrix attaches as `unsigned.invite_room_state` on an
1500/// invite's own `m.room.member` event (name/join_rules/encryption/create,
1501/// plus the inviter's own membership event) — recomputed fresh every time
1502/// it is served, NEVER stored on the member event itself: `unsigned` is not
1503/// part of the canonical, signed event, and this server's `events.content`
1504/// column holds only the canonical, signed shape. Computing it at read time
1505/// also means it always reflects the room's CURRENT state (e.g. a rename
1506/// after the invite was sent), which is what a client opening an invite it
1507/// received a while ago actually wants to see. `inviter_user_id` is the
1508/// member event's own `sender_user_id` — the caller already has it.
1509pub fn stripped_invite_state(conn: &Connection, room_id: &str, inviter_user_id: i64) -> Result<Vec<serde_json::Value>, MatrixStoreError> {
1510    let mut out = Vec::new();
1511    for event_type in ["m.room.create", "m.room.join_rules", "m.room.encryption", "m.room.name"] {
1512        if let Some(event) = current_state_event(conn, room_id, event_type, "")? {
1513            out.push(stripped_state_json(conn, &event)?);
1514        }
1515    }
1516    if let Some(inviter_mxid) = mxid_of(conn, inviter_user_id)? {
1517        if let Some(event) = current_state_event(conn, room_id, "m.room.member", &inviter_mxid)? {
1518            out.push(stripped_state_json(conn, &event)?);
1519        }
1520    }
1521    Ok(out)
1522}
1523
1524pub fn get_event(conn: &Connection, event_id: &str) -> rusqlite::Result<Option<MatrixEvent>> {
1525    let closed = conn
1526        .query_row(&format!("SELECT {EVENT_SELECT_COLUMNS} FROM events WHERE event_id = ?1"), params![event_id], event_from_row)
1527        .optional()?;
1528    if closed.is_some() {
1529        return Ok(closed);
1530    }
1531    // Public plaintext store (own tables); absent table on a pre-cut DB is "not found".
1532    match crate::public_channels::get_event(conn, event_id) {
1533        Ok(found) => Ok(found),
1534        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => Ok(None),
1535        Err(e) => Err(e),
1536    }
1537}
1538
1539pub fn current_state_event(conn: &Connection, room_id: &str, event_type: &str, state_key: &str) -> rusqlite::Result<Option<MatrixEvent>> {
1540    conn.query_row(
1541        &format!(
1542            "SELECT {EVENT_SELECT_COLUMNS_ALIASED} FROM current_state cs JOIN events e ON e.event_id = cs.event_id
1543             WHERE cs.room_id = ?1 AND cs.event_type = ?2 AND cs.state_key = ?3"
1544        ),
1545        params![room_id, event_type, state_key],
1546        event_from_row,
1547    )
1548    .optional()
1549}
1550
1551pub fn current_state_all(conn: &Connection, room_id: &str) -> rusqlite::Result<Vec<MatrixEvent>> {
1552    let mut stmt = conn.prepare(&format!(
1553        "SELECT {EVENT_SELECT_COLUMNS_ALIASED} FROM current_state cs JOIN events e ON e.event_id = cs.event_id WHERE cs.room_id = ?1"
1554    ))?;
1555    let mut rows = stmt.query(params![room_id])?;
1556    collect_events(&mut rows)
1557}
1558
1559/// The point-in-time projection of every `event_type` state event as of
1560/// `at_stream_id` — the latest such event per `state_key` with
1561/// `stream_id <= at_stream_id` — for `GET /rooms/{roomId}/members?at=`
1562/// (plan §3.3: a client fetching the full member list as of a given `/sync`
1563/// token, rather than the live [`current_state_all`] projection). A
1564/// `state_key` whose first event lands AFTER `at_stream_id` is correctly
1565/// absent (it did not exist yet at that point in the room's history).
1566pub fn state_events_of_type_at(conn: &Connection, room_id: &str, event_type: &str, at_stream_id: i64) -> rusqlite::Result<Vec<MatrixEvent>> {
1567    let mut stmt = conn.prepare(&format!(
1568        "SELECT {EVENT_SELECT_COLUMNS_ALIASED} FROM events e
1569         WHERE e.room_id = ?1 AND e.event_type = ?2 AND e.state_key IS NOT NULL AND e.stream_id <= ?3
1570           AND e.stream_id = (
1571             SELECT MAX(stream_id) FROM events e2
1572             WHERE e2.room_id = e.room_id AND e2.event_type = e.event_type AND e2.state_key = e.state_key AND e2.stream_id <= ?3
1573           )"
1574    ))?;
1575    let mut rows = stmt.query(params![room_id, event_type, at_stream_id])?;
1576    collect_events(&mut rows)
1577}
1578
1579/// Every `m.room.member` state event whose CURRENT value as of
1580/// `upto_inclusive` was itself set within `(since_exclusive,
1581/// upto_inclusive]` — `routes::matrix::sync`'s §3.3 clause-(b) lazy-load
1582/// "gap rule" (matrix-spec#942): a member who joined/left/changed profile
1583/// inside a truncated timeline gap must still be reported even when they
1584/// never sent anything into the visible window. Bounded to
1585/// `upto_inclusive` throughout (never the LIVE `current_state` projection,
1586/// which could have moved past a `/sync` build's own snapshot) so this
1587/// stays part of one consistent cut.
1588pub fn member_state_changed_in_window(conn: &Connection, room_id: &str, since_exclusive: i64, upto_inclusive: i64) -> rusqlite::Result<Vec<MatrixEvent>> {
1589    let mut stmt = conn.prepare(&format!(
1590        "SELECT {EVENT_SELECT_COLUMNS_ALIASED} FROM events e
1591         WHERE e.room_id = ?1 AND e.event_type = 'm.room.member' AND e.state_key IS NOT NULL
1592           AND e.stream_id = (
1593             SELECT MAX(e2.stream_id) FROM events e2
1594             WHERE e2.room_id = e.room_id AND e2.event_type = 'm.room.member' AND e2.state_key = e.state_key AND e2.stream_id <= ?3
1595           )
1596           AND e.stream_id > ?2"
1597    ))?;
1598    let mut rows = stmt.query(params![room_id, since_exclusive, upto_inclusive])?;
1599    collect_events(&mut rows)
1600}
1601
1602/// Every state event type OTHER than `m.room.member` whose CURRENT value as
1603/// of `upto_inclusive` was itself set within `(since_exclusive,
1604/// upto_inclusive]` — `routes::matrix::sync`'s per-room state delta for
1605/// every state type this server does not lazy-load ("every OTHER
1606/// state-event type changed since `since` ... always included in full").
1607/// `since_exclusive = 0` naturally reproduces every current state event of
1608/// these types (the initial-sync/`full_state` case), since every such
1609/// event's own `stream_id` is `> 0` — one code path serves both.
1610pub fn non_member_state_changed_in_window(conn: &Connection, room_id: &str, since_exclusive: i64, upto_inclusive: i64) -> rusqlite::Result<Vec<MatrixEvent>> {
1611    let mut stmt = conn.prepare(&format!(
1612        "SELECT {EVENT_SELECT_COLUMNS_ALIASED} FROM events e
1613         WHERE e.room_id = ?1 AND e.event_type != 'm.room.member' AND e.state_key IS NOT NULL
1614           AND e.stream_id = (
1615             SELECT MAX(e2.stream_id) FROM events e2
1616             WHERE e2.room_id = e.room_id AND e2.event_type = e.event_type AND e2.state_key = e.state_key AND e2.stream_id <= ?3
1617           )
1618           AND e.stream_id > ?2"
1619    ))?;
1620    let mut rows = stmt.query(params![room_id, since_exclusive, upto_inclusive])?;
1621    collect_events(&mut rows)
1622}
1623
1624/// The oldest-first timeline window strictly after `since_stream`, capped
1625/// at `limit` — the incremental-`/sync` and forward-`/messages` shape.
1626pub fn events_in_room_after(conn: &Connection, room_id: &str, since_stream: i64, limit: i64) -> rusqlite::Result<Vec<MatrixEvent>> {
1627    let mut stmt = conn.prepare(&format!(
1628        "SELECT {EVENT_SELECT_COLUMNS} FROM events WHERE room_id = ?1 AND stream_id > ?2 ORDER BY stream_id ASC LIMIT ?3"
1629    ))?;
1630    let mut rows = stmt.query(params![room_id, since_stream, limit])?;
1631    let mut out = collect_events(&mut rows)?;
1632    if crate::public_channels::is_public_room(conn, room_id)? {
1633        out.extend(crate::public_channels::events_after(conn, room_id, since_stream, limit)?);
1634        out.sort_by_key(|e| e.stream_id);
1635        out.truncate(limit.max(0) as usize);
1636    }
1637    Ok(out)
1638}
1639
1640/// The newest-first page strictly before `before_stream`, capped at
1641/// `limit` — `GET /messages?dir=b` and the initial-`/sync` "newest N"
1642/// window (§3.2: the caller reverses it if an ascending page is needed).
1643pub fn events_in_room_before(conn: &Connection, room_id: &str, before_stream: i64, limit: i64) -> rusqlite::Result<Vec<MatrixEvent>> {
1644    let mut stmt = conn.prepare(&format!(
1645        "SELECT {EVENT_SELECT_COLUMNS} FROM events WHERE room_id = ?1 AND stream_id < ?2 ORDER BY stream_id DESC LIMIT ?3"
1646    ))?;
1647    let mut rows = stmt.query(params![room_id, before_stream, limit])?;
1648    let mut out = collect_events(&mut rows)?;
1649    if crate::public_channels::is_public_room(conn, room_id)? {
1650        out.extend(crate::public_channels::events_before(conn, room_id, before_stream, limit)?);
1651        out.sort_by_key(|e| std::cmp::Reverse(e.stream_id));
1652        out.truncate(limit.max(0) as usize);
1653    }
1654    Ok(out)
1655}
1656
1657// ============================================================================
1658// Relations (m.annotation / m.replace / m.in_reply_to / m.thread)
1659// ============================================================================
1660
1661/// Populate `relations` from `content`'s top-level `m.relates_to`, if any —
1662/// a no-op if `content` is not JSON, has no `m.relates_to`, or the relation
1663/// shape is unrecognized. Two shapes are handled: the common
1664/// `{"rel_type":..., "event_id":..., "key": "..."}` (annotation/replace/
1665/// thread) and the nested `{"m.in_reply_to": {"event_id": "..."}}` reply
1666/// form.
1667///
1668/// The target must exist and be in the SAME room as `room_id` — refused
1669/// with [`MatrixStoreError::InvalidRelationTarget`] otherwise (a relation
1670/// can never point out of its own room, and a target that does not exist
1671/// would otherwise only surface as an opaque foreign-key `Db` error from
1672/// the `INSERT` below).
1673///
1674/// A second identical `m.annotation` (same sender, target, key) is refused
1675/// with [`MatrixStoreError::DuplicateAnnotation`] — checked BEFORE insert,
1676/// so the caller's whole transaction rolls back and no partial `events` row
1677/// survives the refusal. A REDACTED prior annotation does not count: once a
1678/// reaction is redacted, its sender is free to react with the same key
1679/// again (`e.redacted_by IS NULL` in the duplicate check).
1680fn populate_relations(tx: &Transaction, event_id: &str, room_id: &str, sender_user_id: i64, content: &str) -> Result<(), MatrixStoreError> {
1681    let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
1682        return Ok(());
1683    };
1684    let Some(relates_to) = value.get("m.relates_to") else {
1685        return Ok(());
1686    };
1687
1688    let (rel_type, target_id, agg_key): (String, String, Option<String>) = if let Some(reply) = relates_to.get("m.in_reply_to") {
1689        match reply.get("event_id").and_then(|v| v.as_str()) {
1690            Some(target) => ("m.in_reply_to".to_string(), target.to_string(), None),
1691            None => return Ok(()),
1692        }
1693    } else {
1694        let rel_type = relates_to.get("rel_type").and_then(|v| v.as_str());
1695        let target = relates_to.get("event_id").and_then(|v| v.as_str());
1696        match (rel_type, target) {
1697            (Some(rt), Some(target)) => {
1698                let key = relates_to.get("key").and_then(|v| v.as_str()).map(str::to_string);
1699                (rt.to_string(), target.to_string(), key)
1700            }
1701            _ => return Ok(()),
1702        }
1703    };
1704
1705    let target_room: Option<String> = tx
1706        .query_row("SELECT room_id FROM events WHERE event_id = ?1", params![target_id], |row| row.get(0))
1707        .optional()?;
1708    match target_room {
1709        Some(ref found_room) if found_room == room_id => {}
1710        _ => return Err(MatrixStoreError::InvalidRelationTarget(target_id)),
1711    }
1712
1713    if rel_type == "m.annotation" {
1714        let duplicate: Option<i64> = tx
1715            .query_row(
1716                "SELECT 1 FROM relations r JOIN events e ON e.event_id = r.event_id
1717                 WHERE r.target_id = ?1 AND r.rel_type = 'm.annotation' AND r.agg_key IS ?2 AND e.sender_user_id = ?3
1718                    AND e.redacted_by IS NULL
1719                 LIMIT 1",
1720                params![target_id, agg_key, sender_user_id],
1721                |row| row.get(0),
1722            )
1723            .optional()?;
1724        if duplicate.is_some() {
1725            return Err(MatrixStoreError::DuplicateAnnotation);
1726        }
1727    }
1728
1729    tx.execute(
1730        "INSERT INTO relations (event_id, room_id, rel_type, target_id, agg_key) VALUES (?1, ?2, ?3, ?4, ?5)",
1731        params![event_id, room_id, rel_type, target_id, agg_key],
1732    )?;
1733    Ok(())
1734}
1735
1736/// Every event related to `target_event_id`, newest-first (Matrix's own
1737/// default order for `GET /relations`), optionally narrowed to one
1738/// `rel_type` and/or one `event_type`, strictly before `before_stream`
1739/// (pass [`i64::MAX`] for a first page), capped at `limit` — P6's
1740/// `routes::matrix::messaging::get_relations`.
1741pub fn relations_of(
1742    conn: &Connection,
1743    target_event_id: &str,
1744    rel_type: Option<&str>,
1745    event_type: Option<&str>,
1746    before_stream: i64,
1747    limit: i64,
1748) -> rusqlite::Result<Vec<MatrixEvent>> {
1749    // Anonymous `?` placeholders (not `?N`) — purely positional, so building
1750    // the SQL text and the bound-value list in the SAME order below can
1751    // never desync the way explicit numbered placeholders would if a
1752    // variant's text skipped a number.
1753    let mut sql = format!("SELECT {EVENT_SELECT_COLUMNS_ALIASED} FROM relations r JOIN events e ON e.event_id = r.event_id WHERE r.target_id = ? AND e.stream_id < ?");
1754    let mut values: Vec<&dyn rusqlite::ToSql> = vec![&target_event_id, &before_stream];
1755    if let Some(rt) = &rel_type {
1756        sql.push_str(" AND r.rel_type = ?");
1757        values.push(rt);
1758    }
1759    if let Some(et) = &event_type {
1760        sql.push_str(" AND e.event_type = ?");
1761        values.push(et);
1762    }
1763    sql.push_str(" ORDER BY e.stream_id DESC LIMIT ?");
1764    values.push(&limit);
1765
1766    let mut stmt = conn.prepare(&sql)?;
1767    let mut rows = stmt.query(values.as_slice())?;
1768    collect_events(&mut rows)
1769}
1770
1771// ============================================================================
1772// Redaction (room v11 content allow-list)
1773// ============================================================================
1774
1775/// Strip `content` to the room-version-11 redaction allow-list for
1776/// `event_type` (Matrix Client-Server API, room version 11 redaction
1777/// algorithm — `spec.matrix.org/v1.12/rooms/v11/#redactions`, cited by plan
1778/// §1/§2): `m.room.create` keeps everything; `m.room.member` keeps
1779/// `membership`, `join_authorised_via_users_server`, and
1780/// `third_party_invite.signed` (nested — only `signed` survives inside
1781/// `third_party_invite`); `m.room.join_rules` keeps `join_rule`, `allow`;
1782/// `m.room.power_levels` keeps `ban`, `events`, `events_default`,
1783/// `invite`, `kick`, `redact`, `state_default`, `users`, `users_default`;
1784/// `m.room.history_visibility` keeps `history_visibility`; every other
1785/// type is stripped to `{}`.
1786fn redact_content_per_v11(event_type: &str, content: &str) -> Result<String, MatrixStoreError> {
1787    let value: serde_json::Value = serde_json::from_str(content)?;
1788    let obj = value.as_object().cloned().unwrap_or_default();
1789    let mut kept = serde_json::Map::new();
1790
1791    match event_type {
1792        "m.room.create" => kept = obj,
1793        "m.room.member" => {
1794            for key in ["membership", "join_authorised_via_users_server"] {
1795                if let Some(v) = obj.get(key) {
1796                    kept.insert(key.to_string(), v.clone());
1797                }
1798            }
1799            if let Some(signed) = obj.get("third_party_invite").and_then(|v| v.get("signed")) {
1800                let mut third_party_invite = serde_json::Map::new();
1801                third_party_invite.insert("signed".to_string(), signed.clone());
1802                kept.insert("third_party_invite".to_string(), serde_json::Value::Object(third_party_invite));
1803            }
1804        }
1805        "m.room.join_rules" => {
1806            for key in ["join_rule", "allow"] {
1807                if let Some(v) = obj.get(key) {
1808                    kept.insert(key.to_string(), v.clone());
1809                }
1810            }
1811        }
1812        "m.room.power_levels" => {
1813            for key in [
1814                "ban",
1815                "events",
1816                "events_default",
1817                "invite",
1818                "kick",
1819                "redact",
1820                "state_default",
1821                "users",
1822                "users_default",
1823            ] {
1824                if let Some(v) = obj.get(key) {
1825                    kept.insert(key.to_string(), v.clone());
1826                }
1827            }
1828        }
1829        "m.room.history_visibility" => {
1830            if let Some(v) = obj.get("history_visibility") {
1831                kept.insert("history_visibility".to_string(), v.clone());
1832            }
1833        }
1834        _ => {}
1835    }
1836
1837    Ok(serde_json::Value::Object(kept).to_string())
1838}
1839
1840/// Redact `target_event_id`: store the `m.room.redaction` event itself
1841/// (`redacts = target_event_id`, and `content.redacts = target_event_id`,
1842/// the room-version-11 location clients read), set the target's
1843/// `redacted_by`, and strip the target's `content` per
1844/// [`redact_content_per_v11`]. All inside one transaction.
1845///
1846/// Refuses with:
1847/// - [`MatrixStoreError::UnknownEventId`] if `target_event_id` does not
1848///   exist;
1849/// - [`MatrixStoreError::WrongRoom`] if it exists but is in a different
1850///   room than `room_id`;
1851/// - [`MatrixStoreError::UnredactableEvent`] for `m.room.create` or
1852///   `m.room.encryption` — `m.room.create` is the room's own identity
1853///   anchor (its v11 allow-list already keeps every key, so "redacting" it
1854///   would be a no-op at best), and `m.room.encryption`'s v11 allow-list
1855///   has NO keys at all, meaning a redacted encryption event strips to
1856///   `{}` — indistinguishable from an unencrypted room to anything reading
1857///   current state. This server's rule is that encryption never turns off
1858///   once set (`rooms.is_encrypted`'s own doc comment says the same), so
1859///   the event that turned it on must never become redactable.
1860fn redact_event_in_tx(
1861    tx: &Transaction,
1862    room_id: &str,
1863    target_event_id: &str,
1864    redaction_event_id: &str,
1865    sender_user_id: i64,
1866    reason: Option<&str>,
1867    origin_server_ts: i64,
1868) -> Result<MatrixEvent, MatrixStoreError> {
1869    let target: Option<(String, String, String)> = tx
1870        .query_row(
1871            "SELECT event_type, content, room_id FROM events WHERE event_id = ?1",
1872            params![target_event_id],
1873            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1874        )
1875        .optional()?;
1876    let (target_type, target_content, target_room) = target.ok_or_else(|| MatrixStoreError::UnknownEventId(target_event_id.to_string()))?;
1877    if target_room != room_id {
1878        return Err(MatrixStoreError::WrongRoom(target_event_id.to_string()));
1879    }
1880    if target_type == "m.room.create" || target_type == "m.room.encryption" {
1881        return Err(MatrixStoreError::UnredactableEvent(target_type));
1882    }
1883
1884    let stream_id = next_stream_id(tx)?;
1885    // Room version 11 carries the redaction's target in `content.redacts`
1886    // (the `redacts` column keeps serving the older top-level shape).
1887    let mut redaction_content = serde_json::json!({ "redacts": target_event_id });
1888    if let Some(r) = reason {
1889        redaction_content["reason"] = serde_json::Value::String(r.to_string());
1890    }
1891    let redaction_content = redaction_content.to_string();
1892    tx.execute(
1893        "INSERT INTO events (stream_id, event_id, room_id, sender_user_id, event_type, state_key, content, origin_server_ts, txn_id, redacts)
1894         VALUES (?1, ?2, ?3, ?4, 'm.room.redaction', NULL, ?5, ?6, NULL, ?7)",
1895        params![stream_id, redaction_event_id, room_id, sender_user_id, redaction_content, origin_server_ts, target_event_id],
1896    )?;
1897
1898    let stripped_content = redact_content_per_v11(&target_type, &target_content)?;
1899    tx.execute(
1900        "UPDATE events SET content = ?1, redacted_by = ?2 WHERE event_id = ?3",
1901        params![stripped_content, redaction_event_id, target_event_id],
1902    )?;
1903
1904    Ok(MatrixEvent {
1905        stream_id,
1906        event_id: redaction_event_id.to_string(),
1907        room_id: room_id.to_string(),
1908        sender_user_id,
1909        event_type: "m.room.redaction".to_string(),
1910        state_key: None,
1911        content: redaction_content,
1912        origin_server_ts,
1913        txn_id: None,
1914        redacts: Some(target_event_id.to_string()),
1915        redacted_by: None,
1916    })
1917}
1918
1919pub fn redact_event(
1920    conn: &mut Connection,
1921    room_id: &str,
1922    target_event_id: &str,
1923    redaction_event_id: &str,
1924    sender_user_id: i64,
1925    reason: Option<&str>,
1926    origin_server_ts: i64,
1927) -> Result<MatrixEvent, MatrixStoreError> {
1928    let tx = conn.transaction()?;
1929    let event = redact_event_in_tx(&tx, room_id, target_event_id, redaction_event_id, sender_user_id, reason, origin_server_ts)?;
1930    tx.commit()?;
1931    Ok(event)
1932}
1933
1934/// The redaction [`redact_event_marked`] writes: which event, in which room,
1935/// by whom, under which new event id.
1936#[derive(Debug, Clone, Copy)]
1937pub struct Redaction<'a> {
1938    pub room_id: &'a str,
1939    pub target_event_id: &'a str,
1940    pub redaction_event_id: &'a str,
1941    pub sender_user_id: i64,
1942    pub reason: Option<&'a str>,
1943    pub origin_server_ts: i64,
1944}
1945
1946/// [`redact_event`], but merges `extra_content` into the resulting
1947/// `m.room.redaction` event's own content, in the SAME transaction as the
1948/// redaction itself — `routes::matrix::moderation`'s (P11) ONLY caller,
1949/// which stamps `{"org.example.site_moderation": true}` on a
1950/// site-moderator's redaction of a public-room message (plan §4's
1951/// `/api/matrix-admin/rooms/{roomId}/moderate` row) so every client can
1952/// render it distinctly from an ordinary member-initiated redaction. Every
1953/// other redaction path ([`redact_event`], [`redact_event_deduped`]) never
1954/// needs this and stays untouched.
1955pub fn redact_event_marked(
1956    conn: &mut Connection,
1957    redaction: &Redaction<'_>,
1958    extra_content: &serde_json::Value,
1959) -> Result<MatrixEvent, MatrixStoreError> {
1960    let Redaction { room_id, target_event_id, redaction_event_id, sender_user_id, reason, origin_server_ts } = *redaction;
1961    let tx = conn.transaction()?;
1962    let mut event = redact_event_in_tx(&tx, room_id, target_event_id, redaction_event_id, sender_user_id, reason, origin_server_ts)?;
1963    let mut content: serde_json::Value = serde_json::from_str(&event.content)?;
1964    if let (Some(content_obj), Some(extra_obj)) = (content.as_object_mut(), extra_content.as_object()) {
1965        for (key, value) in extra_obj {
1966            content_obj.insert(key.clone(), value.clone());
1967        }
1968    }
1969    let content_str = content.to_string();
1970    tx.execute("UPDATE events SET content = ?1 WHERE event_id = ?2", params![content_str, redaction_event_id])?;
1971    event.content = content_str;
1972    tx.commit()?;
1973    Ok(event)
1974}
1975
1976/// [`redact_event`], but dedup-checked and recorded in the SAME transaction
1977/// as the redaction (P6 binding rule, mirroring
1978/// [`insert_timeline_event_deduped`] — see its own doc for the race-freedom
1979/// argument).
1980#[allow(clippy::too_many_arguments)]
1981pub fn redact_event_deduped(
1982    conn: &mut Connection,
1983    device_id: &str,
1984    txn_id: &str,
1985    room_id: &str,
1986    target_event_id: &str,
1987    redaction_event_id: &str,
1988    sender_user_id: i64,
1989    reason: Option<&str>,
1990    origin_server_ts: i64,
1991    now: &str,
1992) -> Result<DedupedWrite, MatrixStoreError> {
1993    let tx = conn.transaction()?;
1994    if let TxnDedupEntry::Seen(existing_event_id) = txn_dedup_lookup(&tx, sender_user_id, device_id, txn_id)? {
1995        let existing_event_id = existing_event_id.ok_or_else(|| MatrixStoreError::UnknownEventId(txn_id.to_string()))?;
1996        let event = get_event(&tx, &existing_event_id)?.ok_or_else(|| MatrixStoreError::UnknownEventId(existing_event_id.clone()))?;
1997        tx.commit()?;
1998        return Ok(DedupedWrite::Existing(event));
1999    }
2000    let event = redact_event_in_tx(&tx, room_id, target_event_id, redaction_event_id, sender_user_id, reason, origin_server_ts)?;
2001    txn_dedup_record(&tx, sender_user_id, device_id, txn_id, Some(redaction_event_id), now)?;
2002    tx.commit()?;
2003    Ok(DedupedWrite::New(event))
2004}
2005
2006// ============================================================================
2007// Membership projection
2008// ============================================================================
2009
2010#[derive(Debug, Clone, PartialEq)]
2011pub struct RoomMember {
2012    pub room_id: String,
2013    pub user_id: i64,
2014    pub membership: Membership,
2015    pub power_level: Option<i64>,
2016    pub updated_at: String,
2017}
2018
2019fn room_member_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RoomMember> {
2020    let membership_raw: String = row.get(2)?;
2021    Ok(RoomMember {
2022        room_id: row.get(0)?,
2023        user_id: row.get(1)?,
2024        membership: decode_enum(2, "membership", &membership_raw, Membership::from_wire_name)?,
2025        power_level: row.get(3)?,
2026        updated_at: row.get(4)?,
2027    })
2028}
2029
2030const ROOM_MEMBER_SELECT_COLUMNS: &str = "room_id, user_id, membership, power_level, updated_at";
2031
2032/// Every member row of `room_id`, optionally filtered to one `membership`.
2033pub fn room_members(conn: &Connection, room_id: &str, membership: Option<Membership>) -> rusqlite::Result<Vec<RoomMember>> {
2034    match membership {
2035        Some(m) => {
2036            let mut stmt = conn.prepare(&format!(
2037                "SELECT {ROOM_MEMBER_SELECT_COLUMNS} FROM room_members WHERE room_id = ?1 AND membership = ?2"
2038            ))?;
2039            let rows = stmt.query_map(params![room_id, m.as_str()], room_member_from_row)?;
2040            rows.collect()
2041        }
2042        None => {
2043            let mut stmt = conn.prepare(&format!("SELECT {ROOM_MEMBER_SELECT_COLUMNS} FROM room_members WHERE room_id = ?1"))?;
2044            let rows = stmt.query_map(params![room_id], room_member_from_row)?;
2045            rows.collect()
2046        }
2047    }
2048}
2049
2050/// `(room_id, user_id)`'s single `room_members` row, if any — the targeted
2051/// counterpart of [`room_members`] for a "is this one user a member, and
2052/// what membership/power level do they hold" check (every gate in
2053/// `routes::matrix::rooms` needs exactly this, not a full-room scan).
2054pub fn room_member(conn: &Connection, room_id: &str, user_id: i64) -> rusqlite::Result<Option<RoomMember>> {
2055    conn.query_row(
2056        &format!("SELECT {ROOM_MEMBER_SELECT_COLUMNS} FROM room_members WHERE room_id = ?1 AND user_id = ?2"),
2057        params![room_id, user_id],
2058        room_member_from_row,
2059    )
2060    .optional()
2061}
2062
2063/// Delete `user_id`'s `room_members` row in `room_id`, but ONLY if its
2064/// current membership is `leave` — `POST /rooms/{roomId}/forget`'s own gate
2065/// AND its effect are the same one-row conditional delete, so there is no
2066/// separate read-then-write race to worry about. Returns the number of rows
2067/// deleted (`0` if the row was absent or not currently `leave`), so the
2068/// route layer can tell a genuine forget from a no-op gate refusal.
2069pub fn forget_membership(conn: &Connection, room_id: &str, user_id: i64) -> rusqlite::Result<usize> {
2070    conn.execute(
2071        "DELETE FROM room_members WHERE room_id = ?1 AND user_id = ?2 AND membership = 'leave'",
2072        params![room_id, user_id],
2073    )
2074}
2075
2076/// Up to `limit` other members (`join`/`invite`) of `room_id`, oldest
2077/// membership-change first, excluding `exclude_user_id` — `/sync`'s
2078/// `m.heroes` (plan §3.4).
2079pub fn room_heroes(conn: &Connection, room_id: &str, exclude_user_id: i64, limit: i64) -> rusqlite::Result<Vec<i64>> {
2080    let mut stmt = conn.prepare(
2081        "SELECT user_id FROM room_members
2082         WHERE room_id = ?1 AND user_id != ?2 AND membership IN ('join', 'invite')
2083         ORDER BY updated_at ASC LIMIT ?3",
2084    )?;
2085    let rows = stmt.query_map(params![room_id, exclude_user_id, limit], |row| row.get(0))?;
2086    rows.collect()
2087}
2088
2089/// Every room id `user_id` has a `room_members` row in, optionally filtered
2090/// to one `membership` — the "which rooms is this user in" query behind
2091/// `/sync`'s room list and `on_credential_revoked`'s wake fan-out.
2092pub fn rooms_for_user(conn: &Connection, user_id: i64, membership: Option<Membership>) -> rusqlite::Result<Vec<String>> {
2093    match membership {
2094        Some(m) => {
2095            let mut stmt = conn.prepare("SELECT room_id FROM room_members WHERE user_id = ?1 AND membership = ?2")?;
2096            let rows = stmt.query_map(params![user_id, m.as_str()], |row| row.get(0))?;
2097            rows.collect()
2098        }
2099        None => {
2100            let mut stmt = conn.prepare("SELECT room_id FROM room_members WHERE user_id = ?1")?;
2101            let rows = stmt.query_map(params![user_id], |row| row.get(0))?;
2102            rows.collect()
2103        }
2104    }
2105}
2106
2107/// Every room in `room_ids` (typically the caller's currently joined rooms)
2108/// with at least one `events`, `receipts`, or room-scoped `account_data` row
2109/// whose `stream_id` falls in `(since_exclusive, upto_inclusive]` —
2110/// `routes::matrix::sync`'s changed-room prefilter: an incremental sync must
2111/// not run a room block builder's own dozen-odd queries against EVERY
2112/// joined room just to discover that most did not change (with a few
2113/// hundred rooms that is thousands of queries per wake, all held under the
2114/// single `messenger.db` mutex). This set is EXACT, not merely a safe
2115/// over-approximation: every field a room's `/sync` block can ever report
2116/// (`timeline`, `state`, per-room `account_data`, and `m.receipt`) derives
2117/// from exactly these three tables — the one exception, typing, is an
2118/// in-memory `crate::typing::TypingRegistry` fact this function has no visibility
2119/// into and the caller checks separately. Returns an empty set without
2120/// querying when `room_ids` is empty.
2121pub fn rooms_changed_in_window(
2122    conn: &Connection,
2123    room_ids: &[String],
2124    caller_user_id: i64,
2125    since_exclusive: i64,
2126    upto_inclusive: i64,
2127) -> rusqlite::Result<HashSet<String>> {
2128    let mut changed = HashSet::new();
2129    if room_ids.is_empty() {
2130        return Ok(changed);
2131    }
2132    let placeholders = vec!["?"; room_ids.len()].join(",");
2133
2134    // `events` and `receipts` are not scoped to `caller_user_id` at all
2135    // (any member's write counts), so both need the `room_id IN (...)`
2136    // restriction to the caller's own joined-room set.
2137    for table in ["events", "receipts", "pub_events"] {
2138        let sql = format!("SELECT DISTINCT room_id FROM {table} WHERE stream_id > ? AND stream_id <= ? AND room_id IN ({placeholders})");
2139        let mut stmt = conn.prepare(&sql)?;
2140        let mut bound: Vec<&dyn rusqlite::ToSql> = vec![&since_exclusive, &upto_inclusive];
2141        for room_id in room_ids {
2142            bound.push(room_id as &dyn rusqlite::ToSql);
2143        }
2144        let mut rows = stmt.query(bound.as_slice())?;
2145        while let Some(row) = rows.next()? {
2146            changed.insert(row.get::<_, String>(0)?);
2147        }
2148    }
2149
2150    // `account_data` rows are already scoped to ONE user — narrowing by
2151    // `user_id` first (its own indexed column, `idx_account_data_user_stream`)
2152    // is cheaper than another `room_id IN (...)` list, then intersect with
2153    // `room_ids` in Rust (global account data, `room_id = ''`, never
2154    // matches a real room id and is naturally excluded by that intersection).
2155    let room_id_set: HashSet<&str> = room_ids.iter().map(String::as_str).collect();
2156    let mut stmt = conn.prepare("SELECT DISTINCT room_id FROM account_data WHERE user_id = ?1 AND stream_id > ?2 AND stream_id <= ?3")?;
2157    let mut rows = stmt.query(params![caller_user_id, since_exclusive, upto_inclusive])?;
2158    while let Some(row) = rows.next()? {
2159        let room_id: String = row.get(0)?;
2160        if room_id_set.contains(room_id.as_str()) {
2161            changed.insert(room_id);
2162        }
2163    }
2164
2165    Ok(changed)
2166}
2167
2168/// `mxid`'s membership in `room_id` as of `at_stream_id`: the latest
2169/// `m.room.member` event for that state key with `stream_id <= at_stream_id`,
2170/// or `None` when there was none yet (or its content carries no known
2171/// membership). The point-in-time counterpart of [`room_member`] — the
2172/// device-list delta needs "what was this user's membership BEFORE the sync
2173/// window opened" to tell a newly shared room from a profile update.
2174pub fn membership_at(conn: &Connection, room_id: &str, mxid: &str, at_stream_id: i64) -> rusqlite::Result<Option<Membership>> {
2175    let content: Option<String> = conn
2176        .query_row(
2177            "SELECT content FROM events
2178             WHERE room_id = ?1 AND event_type = 'm.room.member' AND state_key = ?2 AND stream_id <= ?3
2179             ORDER BY stream_id DESC LIMIT 1",
2180            params![room_id, mxid, at_stream_id],
2181            |row| row.get(0),
2182        )
2183        .optional()?;
2184    Ok(content
2185        .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
2186        .and_then(|value| value.get("membership").and_then(|m| m.as_str()).and_then(Membership::from_wire_name)))
2187}
2188
2189/// Every room in `room_ids` that has at least one `m.room.member` event with
2190/// `stream_id` in `(from_exclusive, to_inclusive]` — ONE indexed query for
2191/// the whole room set, so the device-list delta only does per-room work for
2192/// rooms where a membership could have changed (an incremental `/sync` runs
2193/// it on every wake; see [`rooms_changed_in_window`] for the same rule).
2194/// Returns an empty vec without querying when `room_ids` is empty.
2195pub fn rooms_with_member_events_in_window(conn: &Connection, room_ids: &[String], from_exclusive: i64, to_inclusive: i64) -> rusqlite::Result<Vec<String>> {
2196    if room_ids.is_empty() {
2197        return Ok(Vec::new());
2198    }
2199    let placeholders = vec!["?"; room_ids.len()].join(",");
2200    let sql = format!(
2201        "SELECT DISTINCT room_id FROM events
2202         WHERE event_type = 'm.room.member' AND stream_id > ? AND stream_id <= ? AND room_id IN ({placeholders})"
2203    );
2204    let mut stmt = conn.prepare(&sql)?;
2205    let mut bound: Vec<&dyn rusqlite::ToSql> = vec![&from_exclusive, &to_inclusive];
2206    for room_id in room_ids {
2207        bound.push(room_id as &dyn rusqlite::ToSql);
2208    }
2209    let rows = stmt.query_map(bound.as_slice(), |row| row.get(0))?;
2210    rows.collect()
2211}
2212
2213/// Every mxid (state key) that has an `m.room.member` event in `room_id`
2214/// with `stream_id` in `(from_exclusive, to_inclusive]` — the candidates
2215/// whose membership MAY have changed inside the window; the caller compares
2216/// [`room_member`] against [`membership_at`] to find the real transitions.
2217pub fn member_state_keys_in_window(conn: &Connection, room_id: &str, from_exclusive: i64, to_inclusive: i64) -> rusqlite::Result<Vec<String>> {
2218    let mut stmt = conn.prepare(
2219        "SELECT DISTINCT state_key FROM events
2220         WHERE room_id = ?1 AND event_type = 'm.room.member' AND state_key IS NOT NULL AND stream_id > ?2 AND stream_id <= ?3",
2221    )?;
2222    let rows = stmt.query_map(params![room_id, from_exclusive, to_inclusive], |row| row.get(0))?;
2223    rows.collect()
2224}
2225
2226/// Every `user_id` whose `m.room.member` state transitioned to `leave` or
2227/// `ban` in one of `room_ids`, with `stream_id` in `(from_exclusive,
2228/// to_inclusive]` — `routes::matrix::keys`'s `GET /keys/changes`
2229/// `device_lists.left` set (plan §3.7): a user who no longer shares any room
2230/// with the caller. This function only finds the CANDIDATE departures inside
2231/// the given room set; the caller (which already knows its own current
2232/// shared-room membership) is responsible for the second half of the plan's
2233/// rule — excluding anyone who still shares SOME OTHER room with it today.
2234/// Bounded to `room_ids` (typically every room the caller is or was in)
2235/// rather than scanning every room this server has ever created. Returns an
2236/// empty vec without querying when `room_ids` is empty.
2237pub fn user_ids_with_leave_transition_in_rooms(
2238    conn: &Connection,
2239    room_ids: &[String],
2240    from_exclusive: i64,
2241    to_inclusive: i64,
2242) -> rusqlite::Result<Vec<i64>> {
2243    if room_ids.is_empty() {
2244        return Ok(Vec::new());
2245    }
2246    let placeholders = vec!["?"; room_ids.len()].join(",");
2247    let sql = format!(
2248        "SELECT DISTINCT e.state_key, e.content FROM events e
2249         WHERE e.event_type = 'm.room.member' AND e.state_key IS NOT NULL
2250           AND e.stream_id > ? AND e.stream_id <= ?
2251           AND e.room_id IN ({placeholders})"
2252    );
2253    let mut stmt = conn.prepare(&sql)?;
2254    let mut bound: Vec<&dyn rusqlite::ToSql> = vec![&from_exclusive, &to_inclusive];
2255    for room_id in room_ids {
2256        bound.push(room_id as &dyn rusqlite::ToSql);
2257    }
2258    let mut rows = stmt.query(bound.as_slice())?;
2259    let mut mxids = Vec::new();
2260    while let Some(row) = rows.next()? {
2261        let mxid: String = row.get(0)?;
2262        let content: String = row.get(1)?;
2263        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
2264            if matches!(value.get("membership").and_then(|m| m.as_str()), Some("leave") | Some("ban")) {
2265                mxids.push(mxid);
2266            }
2267        }
2268    }
2269    let mut user_ids = Vec::new();
2270    for mxid in mxids {
2271        if let Some(user_id) = user_id_of(conn, &mxid)? {
2272            user_ids.push(user_id);
2273        }
2274    }
2275    Ok(user_ids)
2276}
2277
2278/// Timeline (non-state, non-redaction) events `sender_user_id` has sent into
2279/// any PRIVATE room (`join_rule = 'invite'`) with `origin_server_ts >=
2280/// since_ms` — `routes::matrix::messaging`'s per-sender daily send-rate
2281/// counter (plan §3.8: "mirroring `DM_MESSAGE_DAILY_LIMIT`... own copy of
2282/// the constant" — the constant itself lives in the route module per this
2283/// codebase's convention; this function is only the count query, since raw
2284/// SQL against `events`/`rooms` stays inside this module). State events
2285/// (`state_key IS NOT NULL`, sent via `/state`) and redactions are excluded
2286/// — this counts actual message-shaped sends, not every write a sender
2287/// makes.
2288pub fn private_room_messages_sent_since(conn: &Connection, sender_user_id: i64, since_ms: i64) -> rusqlite::Result<i64> {
2289    conn.query_row(
2290        "SELECT COUNT(*) FROM events e JOIN rooms r ON r.id = e.room_id
2291         WHERE e.sender_user_id = ?1 AND e.state_key IS NULL AND e.event_type != 'm.room.redaction'
2292           AND e.origin_server_ts >= ?2 AND r.join_rule = 'invite'",
2293        params![sender_user_id, since_ms],
2294        |row| row.get(0),
2295    )
2296}
2297
2298/// How many `(user_id, device_id)` sends/redacts were recorded since `since`
2299/// (an RFC-3339 timestamp, compared as a plain string against `created_at`
2300/// values this server ALWAYS writes in that same shape — safe without the
2301/// `datetime()` normalization `lookup_web_credential`'s own doc warns about,
2302/// which is specifically about comparing against SQLite's own `datetime()`
2303/// output, a different textual shape) — `routes::matrix::messaging`'s
2304/// short-window burst-rate counter (plan §3.8: "20 events / 10s per
2305/// device"). Reads `txn_dedup`, the one table that already carries a
2306/// per-DEVICE identity for a write (`events` itself has no `device_id`
2307/// column).
2308pub fn txn_dedup_count_since(conn: &Connection, user_id: i64, device_id: &str, since: &str) -> rusqlite::Result<i64> {
2309    conn.query_row(
2310        "SELECT COUNT(*) FROM txn_dedup WHERE user_id = ?1 AND device_id = ?2 AND created_at >= ?3",
2311        params![user_id, device_id, since],
2312        |row| row.get(0),
2313    )
2314}
2315
2316// ============================================================================
2317// History visibility (P6 binding rule, shared by `/messages`, `/event`,
2318// `/relations`, and exported for P10's `/sync`)
2319// ============================================================================
2320
2321/// How much of a room's timeline a caller may read, per
2322/// [`visible_upper_bound`]. `UpTo` is INCLUSIVE (a caller who left the room
2323/// still sees the `m.room.member` leave event itself, since that is the last
2324/// event they witnessed).
2325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2326pub enum HistoryWindow {
2327    /// No read access to this room's timeline at all.
2328    Nothing,
2329    /// Every event, unbounded.
2330    All,
2331    /// Every event with `stream_id <= this`.
2332    UpTo(i64),
2333}
2334
2335impl HistoryWindow {
2336    pub fn contains(self, stream_id: i64) -> bool {
2337        match self {
2338            HistoryWindow::Nothing => false,
2339            HistoryWindow::All => true,
2340            HistoryWindow::UpTo(upto) => stream_id <= upto,
2341        }
2342    }
2343}
2344
2345/// The history-visibility rule every timeline-reading route enforces
2346/// (`routes::matrix::messaging`'s `/messages`, `/event`, `/relations`; P10's
2347/// `/sync` reuses this too):
2348///
2349/// - `world_readable`: ANY signed-in caller sees the WHOLE timeline
2350///   ([`HistoryWindow::All`]), regardless of their own membership — this is
2351///   the one case where a caller who was never a member of the room at all
2352///   still reads it.
2353/// - Every other `history_visibility` value (`shared`, and this server's
2354///   simplified handling of `invited`/`joined` — v1 does not implement their
2355///   finer per-event lower bound, see the module doc): a currently `join`ed
2356///   member sees the whole timeline ([`HistoryWindow::All`]); a member whose
2357///   CURRENT membership is `leave`/`ban` sees only up to the stream position
2358///   of that leave/ban event itself ([`HistoryWindow::UpTo`], read off the
2359///   `m.room.member` state event's own `stream_id` — it is, by definition,
2360///   the LATEST such event for that member, since that is what "current
2361///   membership" means); anyone else (never touched this room's membership
2362///   at all, or is merely `invite`d without ever having joined) sees
2363///   [`HistoryWindow::Nothing`].
2364pub fn visible_upper_bound(conn: &Connection, room: &Room, caller_user_id: i64) -> Result<HistoryWindow, MatrixStoreError> {
2365    if room.history_visibility == HistoryVisibility::WorldReadable {
2366        return Ok(HistoryWindow::All);
2367    }
2368    let Some(mxid) = mxid_of(conn, caller_user_id)? else {
2369        return Ok(HistoryWindow::Nothing);
2370    };
2371    let Some(member_event) = current_state_event(conn, &room.id, "m.room.member", &mxid)? else {
2372        return Ok(HistoryWindow::Nothing);
2373    };
2374    let content: serde_json::Value = serde_json::from_str(&member_event.content)?;
2375    match content.get("membership").and_then(|v| v.as_str()) {
2376        Some("join") => Ok(HistoryWindow::All),
2377        Some("leave") | Some("ban") => Ok(HistoryWindow::UpTo(member_event.stream_id)),
2378        _ => Ok(HistoryWindow::Nothing),
2379    }
2380}
2381
2382// ============================================================================
2383// Receipts
2384// ============================================================================
2385
2386#[derive(Debug, Clone, PartialEq)]
2387pub struct ReceiptRow {
2388    pub room_id: String,
2389    pub user_id: i64,
2390    pub receipt_type: ReceiptType,
2391    pub event_id: String,
2392    pub ts: i64,
2393    pub stream_id: i64,
2394}
2395
2396fn receipt_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ReceiptRow> {
2397    let receipt_type_raw: String = row.get(2)?;
2398    Ok(ReceiptRow {
2399        room_id: row.get(0)?,
2400        user_id: row.get(1)?,
2401        receipt_type: decode_enum(2, "receipt_type", &receipt_type_raw, ReceiptType::from_wire_name)?,
2402        event_id: row.get(3)?,
2403        ts: row.get(4)?,
2404        stream_id: row.get(5)?,
2405    })
2406}
2407
2408const RECEIPT_SELECT_COLUMNS: &str = "room_id, user_id, receipt_type, event_id, ts, stream_id";
2409
2410pub fn get_receipt(conn: &Connection, room_id: &str, user_id: i64, receipt_type: ReceiptType) -> rusqlite::Result<Option<ReceiptRow>> {
2411    conn.query_row(
2412        &format!("SELECT {RECEIPT_SELECT_COLUMNS} FROM receipts WHERE room_id = ?1 AND user_id = ?2 AND receipt_type = ?3"),
2413        params![room_id, user_id, receipt_type.as_str()],
2414        receipt_from_row,
2415    )
2416    .optional()
2417}
2418
2419/// Every receipt row in `room_id` whose own write-order `stream_id` (NOT the
2420/// TARGET event's position — see [`upsert_receipt`]'s own doc on that
2421/// distinction) falls in `(since_exclusive, upto_inclusive]` —
2422/// `routes::matrix::sync`'s per-room `m.receipt` ephemeral delta (plan
2423/// §3.6).
2424pub fn receipts_changed_in_room(conn: &Connection, room_id: &str, since_exclusive: i64, upto_inclusive: i64) -> rusqlite::Result<Vec<ReceiptRow>> {
2425    let mut stmt = conn.prepare(&format!(
2426        "SELECT {RECEIPT_SELECT_COLUMNS} FROM receipts WHERE room_id = ?1 AND stream_id > ?2 AND stream_id <= ?3"
2427    ))?;
2428    let rows = stmt.query_map(params![room_id, since_exclusive, upto_inclusive], receipt_from_row)?;
2429    rows.collect()
2430}
2431
2432/// Upsert `user_id`'s `receipt_type` receipt in `room_id`, refusing to move
2433/// it BACKWARDS: compared by the TARGET events' own `stream_id` (timeline
2434/// position), never by the receipt row's own `stream_id` column (which
2435/// orders receipt *writes* for `/sync`'s ephemeral-since-last-batch filter,
2436/// a different axis). Returns the resulting `stream_id` stamped on the
2437/// row — the existing one, unchanged, on a refused backward move.
2438///
2439/// Refuses with [`MatrixStoreError::UnknownEventId`] if `event_id` does not
2440/// exist, or [`MatrixStoreError::WrongRoom`] if it exists but is in a
2441/// different room than `room_id` — a receipt can never point at an event
2442/// outside the room it is filed in.
2443pub fn upsert_receipt(
2444    conn: &mut Connection,
2445    room_id: &str,
2446    user_id: i64,
2447    receipt_type: ReceiptType,
2448    event_id: &str,
2449    ts: i64,
2450) -> Result<i64, MatrixStoreError> {
2451    let tx = conn.transaction()?;
2452    let stream_id = upsert_receipt_in_tx(&tx, room_id, user_id, receipt_type, event_id, ts)?;
2453    tx.commit()?;
2454    Ok(stream_id)
2455}
2456
2457/// The `&Transaction`-scoped core of [`upsert_receipt`] — split out the same
2458/// way [`insert_timeline_event_in_tx`] is split from [`insert_timeline_event`],
2459/// so [`catch_up_dm_conversation`] (P15: bridging a live legacy DM send, or a
2460/// boot pass catching one up) can advance a reader's receipt in the SAME
2461/// transaction as the messages that reader's receipt targets, rather than a
2462/// second one — `upsert_receipt` itself opens its own transaction and so
2463/// cannot be nested inside a caller's own `Transaction` on the same
2464/// connection.
2465fn upsert_receipt_in_tx(
2466    tx: &Transaction,
2467    room_id: &str,
2468    user_id: i64,
2469    receipt_type: ReceiptType,
2470    event_id: &str,
2471    ts: i64,
2472) -> Result<i64, MatrixStoreError> {
2473    let target: Option<(i64, String)> = tx
2474        .query_row("SELECT stream_id, room_id FROM events WHERE event_id = ?1", params![event_id], |row| {
2475            Ok((row.get(0)?, row.get(1)?))
2476        })
2477        .optional()?;
2478    let Some((target_position, target_room)) = target else {
2479        // A read receipt on a public-store post is accepted and not stored:
2480        // the public store keeps no per-user read state.
2481        if let Some(public) = crate::public_channels::get_event(tx, event_id)? {
2482            if public.room_id != room_id {
2483                return Err(MatrixStoreError::WrongRoom(event_id.to_string()));
2484            }
2485            return Ok(public.stream_id);
2486        }
2487        return Err(MatrixStoreError::UnknownEventId(event_id.to_string()));
2488    };
2489    if target_room != room_id {
2490        return Err(MatrixStoreError::WrongRoom(event_id.to_string()));
2491    }
2492
2493    let existing: Option<(String, i64)> = tx
2494        .query_row(
2495            "SELECT event_id, stream_id FROM receipts WHERE room_id = ?1 AND user_id = ?2 AND receipt_type = ?3",
2496            params![room_id, user_id, receipt_type.as_str()],
2497            |row| Ok((row.get(0)?, row.get(1)?)),
2498        )
2499        .optional()?;
2500
2501    if let Some((existing_event_id, existing_stream_id)) = &existing {
2502        let existing_position: i64 =
2503            tx.query_row("SELECT stream_id FROM events WHERE event_id = ?1", params![existing_event_id], |row| row.get(0))?;
2504        if target_position <= existing_position {
2505            return Ok(*existing_stream_id);
2506        }
2507    }
2508
2509    let stream_id = next_stream_id(tx)?;
2510    tx.execute(
2511        "INSERT INTO receipts (room_id, user_id, receipt_type, event_id, ts, stream_id)
2512         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
2513         ON CONFLICT(room_id, user_id, receipt_type) DO UPDATE SET
2514            event_id = excluded.event_id, ts = excluded.ts, stream_id = excluded.stream_id",
2515        params![room_id, user_id, receipt_type.as_str(), event_id, ts, stream_id],
2516    )?;
2517    Ok(stream_id)
2518}
2519
2520// ============================================================================
2521// Unread notifications (plan §3.5) — `/sync`'s `unread_notifications` block
2522// ============================================================================
2523
2524/// Message-like timeline event types that count toward
2525/// `unread_notifications.notification_count` (plan §3.5) — everything else
2526/// (state events, `m.reaction`, `m.room.redaction`) is excluded. A legacy DM
2527/// migrated from `dm_conversations` (plan §7) lands as
2528/// `org.example.legacy_dm`, which counts the same as a live message.
2529/// Includes historical `org.example.legacy_dm` so unread counts stay correct
2530/// until those timeline rows age out; new writes of that type are refused.
2531const NOTIFICATION_MESSAGE_TYPES_SQL: &str = "('m.room.message', 'm.room.encrypted', 'org.example.legacy_dm')";
2532
2533/// `unread_notifications.notification_count` for `user_id` in `room` (plan
2534/// §3.5): the count of message-like timeline events (see
2535/// [`NOTIFICATION_MESSAGE_TYPES_SQL`]) strictly after the FURTHER-AHEAD of
2536/// the caller's own `m.read`/`m.read.private` receipt — resolved by the
2537/// target events' own `stream_id` (timeline position), never by a receipt
2538/// row's own `stream_id` column (a different axis — see [`upsert_receipt`]'s
2539/// own doc) — and bounded above by [`visible_upper_bound`] (a `leave`/
2540/// `ban`'d member's count never includes anything past their own
2541/// departure). Excludes the caller's own sends (nobody is notified about
2542/// their own message) and, being restricted to
2543/// [`NOTIFICATION_MESSAGE_TYPES_SQL`], every state event and `m.reaction`.
2544/// Not a member of the room at all ([`HistoryWindow::Nothing`]) is always
2545/// `0` — there is nothing this caller could be notified about.
2546///
2547/// `highlight_count` is always `0` from this server (plan §3.5: no push-
2548/// rule/keyword-highlight engine in v1, encrypted or not) — that is a
2549/// constant the `/sync` response builder (P10) sets directly, not a second
2550/// query here.
2551pub fn notification_count(conn: &Connection, room: &Room, user_id: i64) -> Result<i64, MatrixStoreError> {
2552    if room.kind == RoomKind::Channel && !room.is_encrypted {
2553        return Ok(0); // public store: no server-side unread state
2554    }
2555    let upper_bound = match visible_upper_bound(conn, room, user_id)? {
2556        HistoryWindow::Nothing => return Ok(0),
2557        HistoryWindow::All => i64::MAX,
2558        HistoryWindow::UpTo(upper) => upper,
2559    };
2560
2561    let mut after_stream_id: i64 = 0;
2562    for receipt_type in [ReceiptType::Read, ReceiptType::ReadPrivate] {
2563        if let Some(receipt) = get_receipt(conn, &room.id, user_id, receipt_type)? {
2564            if let Some(target) = get_event(conn, &receipt.event_id)? {
2565                after_stream_id = after_stream_id.max(target.stream_id);
2566            }
2567        }
2568    }
2569
2570    let count = conn.query_row(
2571        &format!(
2572            "SELECT COUNT(*) FROM events
2573             WHERE room_id = ?1 AND state_key IS NULL AND sender_user_id != ?2
2574               AND stream_id > ?3 AND stream_id <= ?4
2575               AND event_type IN {NOTIFICATION_MESSAGE_TYPES_SQL}"
2576        ),
2577        params![room.id, user_id, after_stream_id, upper_bound],
2578        |row| row.get(0),
2579    )?;
2580    Ok(count)
2581}
2582
2583// ============================================================================
2584// Account data
2585// ============================================================================
2586
2587/// `room_id` value meaning "global account data" — see [`create_matrix_schema`]'s
2588/// doc comment on why this is `""`, never `NULL`.
2589pub const GLOBAL_ACCOUNT_DATA_ROOM: &str = "";
2590
2591#[derive(Debug, Clone, PartialEq)]
2592pub struct AccountDataRow {
2593    pub user_id: i64,
2594    pub room_id: String,
2595    pub data_type: String,
2596    pub content: String,
2597    pub stream_id: i64,
2598}
2599
2600fn account_data_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AccountDataRow> {
2601    Ok(AccountDataRow {
2602        user_id: row.get(0)?,
2603        room_id: row.get(1)?,
2604        data_type: row.get(2)?,
2605        content: row.get(3)?,
2606        stream_id: row.get(4)?,
2607    })
2608}
2609
2610const ACCOUNT_DATA_SELECT_COLUMNS: &str = "user_id, room_id, data_type, content, stream_id";
2611
2612/// Upsert one account-data entry. Pass [`GLOBAL_ACCOUNT_DATA_ROOM`] for
2613/// global data (`m.direct`, `m.push_rules`, ...); a real room id for
2614/// per-room data.
2615pub fn upsert_account_data(conn: &mut Connection, user_id: i64, room_id: &str, data_type: &str, content: &str) -> Result<i64, MatrixStoreError> {
2616    let tx = conn.transaction()?;
2617    let stream_id = next_stream_id(&tx)?;
2618    tx.execute(
2619        "INSERT INTO account_data (user_id, room_id, data_type, content, stream_id) VALUES (?1, ?2, ?3, ?4, ?5)
2620         ON CONFLICT(user_id, room_id, data_type) DO UPDATE SET content = excluded.content, stream_id = excluded.stream_id",
2621        params![user_id, room_id, data_type, content, stream_id],
2622    )?;
2623    tx.commit()?;
2624    Ok(stream_id)
2625}
2626
2627pub fn get_account_data(conn: &Connection, user_id: i64, room_id: &str, data_type: &str) -> rusqlite::Result<Option<AccountDataRow>> {
2628    conn.query_row(
2629        &format!("SELECT {ACCOUNT_DATA_SELECT_COLUMNS} FROM account_data WHERE user_id = ?1 AND room_id = ?2 AND data_type = ?3"),
2630        params![user_id, room_id, data_type],
2631        account_data_from_row,
2632    )
2633    .optional()
2634}
2635
2636/// Every `(user_id, room_id)` account-data row changed strictly after
2637/// `since_stream` — the `/sync` delta shape for both global and per-room
2638/// account data (caller passes [`GLOBAL_ACCOUNT_DATA_ROOM`] or a real room
2639/// id).
2640pub fn account_data_since(conn: &Connection, user_id: i64, room_id: &str, since_stream: i64) -> rusqlite::Result<Vec<AccountDataRow>> {
2641    let mut stmt = conn.prepare(&format!(
2642        "SELECT {ACCOUNT_DATA_SELECT_COLUMNS} FROM account_data WHERE user_id = ?1 AND room_id = ?2 AND stream_id > ?3 ORDER BY stream_id ASC"
2643    ))?;
2644    let rows = stmt.query_map(params![user_id, room_id, since_stream], account_data_from_row)?;
2645    rows.collect()
2646}
2647
2648// ============================================================================
2649// Txn-id idempotency
2650// ============================================================================
2651
2652/// What [`txn_dedup_lookup`] found for a given `(user_id, device_id, txn_id)`.
2653#[derive(Debug, Clone, PartialEq, Eq)]
2654pub enum TxnDedupEntry {
2655    /// No prior submission recorded — the caller should proceed to create
2656    /// the event/to-device-send and then [`txn_dedup_record`] it.
2657    NotSeen,
2658    /// Already recorded. `Some(event_id)` for a send/redact that produced
2659    /// an event; `None` for a to-device send (which has no `events` row).
2660    Seen(Option<String>),
2661}
2662
2663/// Check whether `(user_id, device_id, txn_id)` was already handled,
2664/// without recording anything.
2665pub fn txn_dedup_lookup(conn: &Connection, user_id: i64, device_id: &str, txn_id: &str) -> rusqlite::Result<TxnDedupEntry> {
2666    let found: Option<Option<String>> = conn
2667        .query_row(
2668            "SELECT event_id FROM txn_dedup WHERE user_id = ?1 AND device_id = ?2 AND txn_id = ?3",
2669            params![user_id, device_id, txn_id],
2670            |row| row.get(0),
2671        )
2672        .optional()?;
2673    Ok(match found {
2674        None => TxnDedupEntry::NotSeen,
2675        Some(event_id) => TxnDedupEntry::Seen(event_id),
2676    })
2677}
2678
2679/// Record that `(user_id, device_id, txn_id)` has now been handled,
2680/// producing `event_id` (or `None` for a to-device send). Call only after
2681/// [`txn_dedup_lookup`] returned [`TxnDedupEntry::NotSeen`] — this does not
2682/// itself check for a race, per this module's single-writer discipline.
2683pub fn txn_dedup_record(conn: &Connection, user_id: i64, device_id: &str, txn_id: &str, event_id: Option<&str>, now: &str) -> rusqlite::Result<()> {
2684    conn.execute(
2685        "INSERT INTO txn_dedup (user_id, device_id, txn_id, event_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
2686        params![user_id, device_id, txn_id, event_id, now],
2687    )?;
2688    Ok(())
2689}
2690
2691/// The `txn_id` `(user_id, device_id)` used to produce `event_id`, if any —
2692/// [`client_event_json`](crate::routes::matrix::client_event_json)'s
2693/// `unsigned.transaction_id`, which Matrix reveals ONLY to the same device
2694/// that sent the event, never to any other viewer (including the sender's
2695/// OTHER devices). A miss (`None`) is the overwhelmingly common case (every
2696/// event not authored by this exact device on this exact send) and costs a
2697/// single indexed `txn_dedup` primary-key lookup.
2698pub fn txn_id_for_event(conn: &Connection, user_id: i64, device_id: &str, event_id: &str) -> rusqlite::Result<Option<String>> {
2699    conn.query_row(
2700        "SELECT txn_id FROM txn_dedup WHERE user_id = ?1 AND device_id = ?2 AND event_id = ?3",
2701        params![user_id, device_id, event_id],
2702        |row| row.get(0),
2703    )
2704    .optional()
2705}
2706
2707// ============================================================================
2708// Filters
2709// ============================================================================
2710
2711/// Store an opaque Filter JSON object, returning its new `filter_id`.
2712pub fn create_filter(conn: &Connection, user_id: i64, definition: &str) -> rusqlite::Result<i64> {
2713    conn.execute("INSERT INTO filters (user_id, definition) VALUES (?1, ?2)", params![user_id, definition])?;
2714    Ok(conn.last_insert_rowid())
2715}
2716
2717/// Fetch `user_id`'s own `filter_id`'s definition — scoped to `user_id` so
2718/// one account can never read another's stored filter by guessing an id.
2719pub fn get_filter(conn: &Connection, user_id: i64, filter_id: i64) -> rusqlite::Result<Option<String>> {
2720    conn.query_row(
2721        "SELECT definition FROM filters WHERE id = ?1 AND user_id = ?2",
2722        params![filter_id, user_id],
2723        |row| row.get(0),
2724    )
2725    .optional()
2726}
2727
2728// ============================================================================
2729// Legacy DM migration map
2730// ============================================================================
2731
2732pub fn insert_legacy_dm_message_map(conn: &Connection, legacy_message_id: i64, event_id: &str) -> rusqlite::Result<()> {
2733    conn.execute(
2734        "INSERT INTO legacy_dm_message_map (legacy_message_id, event_id) VALUES (?1, ?2)",
2735        params![legacy_message_id, event_id],
2736    )?;
2737    Ok(())
2738}
2739
2740pub fn legacy_dm_message_event_id(conn: &Connection, legacy_message_id: i64) -> rusqlite::Result<Option<String>> {
2741    conn.query_row(
2742        "SELECT event_id FROM legacy_dm_message_map WHERE legacy_message_id = ?1",
2743        params![legacy_message_id],
2744        |row| row.get(0),
2745    )
2746    .optional()
2747}
2748
2749/// The highest `legacy_message_id` already imported into `room_id`'s
2750/// `org.example.legacy_dm` timeline (P15: `matrix_migration`'s own
2751/// catch-up cursor). `legacy_dm_message_map` carries no `room_id` of its
2752/// own — `dm_messages.id` is a single autoincrement column shared by every
2753/// legacy conversation, not scoped per conversation — so this joins through
2754/// `events` to find only the rows imported into THIS room. Message ids are
2755/// ascending within one conversation and every migration/catch-up pass
2756/// imports every not-yet-mapped row up to "now", so `id > this` is exactly
2757/// that conversation's not-yet-imported set — the caller's own cheap
2758/// alternative to re-checking `legacy_dm_message_event_id` once per message.
2759/// `None` for a room with no legacy message imported yet (a conversation
2760/// that had zero messages at migration time).
2761pub fn highest_mapped_legacy_message_id(conn: &Connection, room_id: &str) -> rusqlite::Result<Option<i64>> {
2762    conn.query_row(
2763        "SELECT MAX(m.legacy_message_id) FROM legacy_dm_message_map m
2764         JOIN events e ON e.event_id = m.event_id
2765         WHERE e.room_id = ?1",
2766        params![room_id],
2767        |row| row.get(0),
2768    )
2769}
2770
2771// ============================================================================
2772// Legacy DM migration batch (P12) — the binary crate's own `matrix_migration`
2773// module is the only caller. Everything one `dm_conversations` row needs
2774// (room + bootstrap state events + every not-yet-imported message + read
2775// receipts + the `m.direct` account-data hint for both parties) lands in
2776// ONE transaction here, for the same reason `create_room_with_state` is a
2777// batch entry point rather than several independent calls (P5 correction):
2778// a crash partway through must never leave a room with some but not all of
2779// its own bootstrap state, or a room with some but not all of its messages.
2780// This reuses the SAME private `_in_tx` helpers `create_room_with_state`
2781// itself uses (`insert_room_row`, `apply_state_event_in_tx`,
2782// `insert_timeline_event_in_tx`), so a migrated room's rows are written by
2783// the exact same code paths a live `createRoom`/`send` call would use.
2784// ============================================================================
2785
2786/// One legacy `dm_messages` row queued for import (plan §7 step 5).
2787/// `event_id` is minted by the caller ([`crate::matrix_migration`] in the
2788/// binary crate) so it can be recorded in the caller's own bookkeeping (the
2789/// idempotency proof compares event ids across two runs) without a second
2790/// round trip back into this module. `content` is already the full
2791/// `org.example.legacy_dm` JSON body
2792/// (`{"legacy_message_id","legacy_conversation_id","nonce_b64","ciphertext_b64"}`).
2793///
2794/// # `legacy_conversation_id`
2795///
2796/// Both this message's own content AND the room's
2797/// `org.example.legacy_dm_key` state event content (plan §2 manager
2798/// decision 5) carry the legacy `dm_conversations.id` (as
2799/// `legacy_conversation_id`) — a client derives
2800/// `mlc_vault::dm::conversation_key` from that id plus the sender's and its
2801/// own keys, and has no other way to learn which legacy conversation a
2802/// migrated room came from. A `messenger.db` migrated by a build before this
2803/// field existed is missing it on every already-migrated row; that is a
2804/// dev-only concern (nothing has shipped) — recreate the database rather
2805/// than backfill it.
2806#[derive(Debug, Clone, PartialEq)]
2807pub struct LegacyDmMessageImport {
2808    pub legacy_message_id: i64,
2809    pub event_id: String,
2810    pub sender_user_id: i64,
2811    pub content: String,
2812    pub origin_server_ts: i64,
2813}
2814
2815/// One reader's `m.read` receipt to set once its target message has been
2816/// imported — `up_to_legacy_message_id` is the LAST `dm_messages.id` this
2817/// reader actually had `read_at` stamped for in `social.db` (never a later
2818/// one — plan P12 scope: "never over-count as read"). `ts_ms` is that same
2819/// message's own `read_at`, converted to epoch milliseconds.
2820#[derive(Debug, Clone, PartialEq)]
2821pub struct LegacyDmReadReceipt {
2822    pub reader_user_id: i64,
2823    pub up_to_legacy_message_id: i64,
2824    pub ts_ms: i64,
2825}
2826
2827/// One `m.direct` merge hint (plan P12 scope: "merge into their existing
2828/// `m.direct`, do not overwrite other entries") — `user_id`'s global
2829/// `m.direct` account data gains `peer_mxid` mapped to the migrated room id,
2830/// alongside whatever entries it already has.
2831#[derive(Debug, Clone, PartialEq)]
2832pub struct LegacyDmDirectHint {
2833    pub user_id: i64,
2834    pub peer_mxid: String,
2835}
2836
2837/// Everything [`migrate_dm_conversation`] needs beyond the room bootstrap
2838/// itself, grouped into one value for the same "stays lint-clean without
2839/// `#[allow(clippy::too_many_arguments)]`" reason [`RoomBootstrap`]'s own doc
2840/// comment states.
2841#[derive(Debug, Clone, Copy)]
2842pub struct DmMigrationExtras<'a> {
2843    pub messages: &'a [LegacyDmMessageImport],
2844    pub receipts: &'a [LegacyDmReadReceipt],
2845    pub direct_hints: &'a [LegacyDmDirectHint],
2846}
2847
2848/// What one [`migrate_dm_conversation`] call actually wrote.
2849#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2850pub struct DmMigrationCounts {
2851    pub messages_imported: usize,
2852    pub receipts_set: usize,
2853}
2854
2855/// Merge `{peer_mxid: [room_id]}` into `user_id`'s existing GLOBAL `m.direct`
2856/// account data, inside `tx` — every other peer's entry already in the
2857/// object is left untouched, and a `room_id` already present under
2858/// `peer_mxid` is not duplicated.
2859fn merge_m_direct_in_tx(tx: &Transaction, user_id: i64, peer_mxid: &str, room_id: &str) -> Result<(), MatrixStoreError> {
2860    let existing: Option<String> = tx
2861        .query_row(
2862            "SELECT content FROM account_data WHERE user_id = ?1 AND room_id = ?2 AND data_type = 'm.direct'",
2863            params![user_id, GLOBAL_ACCOUNT_DATA_ROOM],
2864            |row| row.get(0),
2865        )
2866        .optional()?;
2867
2868    let mut direct: serde_json::Map<String, serde_json::Value> = match existing {
2869        Some(content) => serde_json::from_str(&content)?,
2870        None => serde_json::Map::new(),
2871    };
2872    let rooms_entry = direct.entry(peer_mxid.to_string()).or_insert_with(|| serde_json::Value::Array(Vec::new()));
2873    if !rooms_entry.is_array() {
2874        *rooms_entry = serde_json::Value::Array(Vec::new());
2875    }
2876    if let serde_json::Value::Array(list) = rooms_entry {
2877        if !list.iter().any(|v| v.as_str() == Some(room_id)) {
2878            list.push(serde_json::Value::String(room_id.to_string()));
2879        }
2880    }
2881
2882    let stream_id = next_stream_id(tx)?;
2883    tx.execute(
2884        "INSERT INTO account_data (user_id, room_id, data_type, content, stream_id) VALUES (?1, ?2, 'm.direct', ?3, ?4)
2885         ON CONFLICT(user_id, room_id, data_type) DO UPDATE SET content = excluded.content, stream_id = excluded.stream_id",
2886        params![user_id, GLOBAL_ACCOUNT_DATA_ROOM, serde_json::Value::Object(direct).to_string(), stream_id],
2887    )?;
2888    Ok(())
2889}
2890
2891/// The P12 migration's own batch entry point: `bootstrap`'s room row, then
2892/// every entry of `state_events` (identical shape to
2893/// [`create_room_with_state`]'s own loop — same order requirement: a
2894/// member's `m.room.member` must precede `m.room.power_levels` for
2895/// [`refresh_power_levels`] to see them, see that function's own doc), then
2896/// every `extras.messages` entry as an `org.example.legacy_dm`
2897/// timeline event plus its `legacy_dm_message_map` row, then
2898/// `extras.receipts`, then `extras.direct_hints` — all in ONE transaction.
2899/// `extras.messages` must already be filtered by the caller to exclude any
2900/// `legacy_message_id` already present in `legacy_dm_message_map` and
2901/// ordered ascending by `legacy_message_id`; this function does not
2902/// re-check either, since the binary crate's own `matrix_migration` module
2903/// already established the room does not exist yet via
2904/// [`room_by_legacy_dm_id`] before ever calling this.
2905pub fn migrate_dm_conversation(
2906    conn: &mut Connection,
2907    bootstrap: RoomBootstrap<'_>,
2908    state_events: &[NewStateEvent],
2909    bootstrap_origin_server_ts: i64,
2910    extras: DmMigrationExtras<'_>,
2911) -> Result<(Room, DmMigrationCounts), MatrixStoreError> {
2912    let tx = conn.transaction()?;
2913
2914    insert_room_row(
2915        &tx,
2916        bootstrap.room_id,
2917        bootstrap.kind,
2918        bootstrap.creator_user_id,
2919        bootstrap.created_at,
2920        bootstrap.is_encrypted,
2921        bootstrap.join_rule,
2922        bootstrap.history_visibility,
2923        bootstrap.dm_pair_key,
2924        bootstrap.legacy_dm_id,
2925    )?;
2926    for event in state_events {
2927        apply_state_event_in_tx(
2928            &tx,
2929            &event.event_id,
2930            bootstrap.room_id,
2931            event.sender_user_id,
2932            &event.event_type,
2933            &event.state_key,
2934            &event.content,
2935            bootstrap_origin_server_ts,
2936            bootstrap.created_at,
2937        )?;
2938    }
2939
2940    let mut event_id_by_legacy_id: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
2941    for message in extras.messages {
2942        insert_timeline_event_in_tx(
2943            &tx,
2944            &TimelineEventRow {
2945                event_id: &message.event_id,
2946                room_id: bootstrap.room_id,
2947                sender_user_id: message.sender_user_id,
2948                event_type: "org.example.legacy_dm",
2949                content: &message.content,
2950                origin_server_ts: message.origin_server_ts,
2951                txn_id: None,
2952            },
2953        )?;
2954        insert_legacy_dm_message_map(&tx, message.legacy_message_id, &message.event_id)?;
2955        event_id_by_legacy_id.insert(message.legacy_message_id, message.event_id.clone());
2956    }
2957
2958    let mut receipts_set = 0usize;
2959    for receipt in extras.receipts {
2960        let Some(target_event_id) = event_id_by_legacy_id.get(&receipt.up_to_legacy_message_id) else { continue };
2961        let target_stream_id: i64 =
2962            tx.query_row("SELECT stream_id FROM events WHERE event_id = ?1", params![target_event_id], |row| row.get(0))?;
2963        tx.execute(
2964            "INSERT INTO receipts (room_id, user_id, receipt_type, event_id, ts, stream_id) VALUES (?1, ?2, 'm.read', ?3, ?4, ?5)",
2965            params![bootstrap.room_id, receipt.reader_user_id, target_event_id, receipt.ts_ms, target_stream_id],
2966        )?;
2967        receipts_set += 1;
2968    }
2969
2970    for hint in extras.direct_hints {
2971        merge_m_direct_in_tx(&tx, hint.user_id, &hint.peer_mxid, bootstrap.room_id)?;
2972    }
2973
2974    tx.commit()?;
2975
2976    Ok((
2977        Room {
2978            id: bootstrap.room_id.to_string(),
2979            kind: bootstrap.kind,
2980            room_version: MATRIX_ROOM_VERSION.to_string(),
2981            creator_user_id: bootstrap.creator_user_id,
2982            created_at: bootstrap.created_at.to_string(),
2983            is_encrypted: bootstrap.is_encrypted,
2984            join_rule: bootstrap.join_rule,
2985            history_visibility: bootstrap.history_visibility,
2986            dm_pair_key: bootstrap.dm_pair_key.map(str::to_string),
2987            legacy_dm_id: bootstrap.legacy_dm_id,
2988        },
2989        DmMigrationCounts { messages_imported: extras.messages.len(), receipts_set },
2990    ))
2991}
2992
2993/// A catch-up pass for an ALREADY migrated room (P15) — the binary crate's
2994/// own `matrix_migration` module calls this both from a live legacy DM
2995/// send's bridge (`routes::dm::create_message`, right after its own insert
2996/// commits) and from a boot pass over a conversation
2997/// [`room_by_legacy_dm_id`] already finds a room for. `messages` (already
2998/// filtered by the caller to exclude anything already in
2999/// `legacy_dm_message_map`, ordered ascending by `legacy_message_id`) lands
3000/// as new `org.example.legacy_dm` timeline events plus their
3001/// `legacy_dm_message_map` rows; then each of `receipts` advances that
3002/// reader's `m.read` receipt — never moved backwards
3003/// ([`upsert_receipt_in_tx`]'s own guarantee, the SAME one a live `/receipt`
3004/// call gets) — all in ONE transaction. Reuses the exact per-event helpers
3005/// [`migrate_dm_conversation`] itself uses, so a caught-up room's rows are
3006/// indistinguishable from ones a fresh migration (or a live Matrix send)
3007/// would have produced.
3008///
3009/// Each `receipt.up_to_legacy_message_id` is resolved via
3010/// [`legacy_dm_message_event_id`] against the FULL map (this transaction's
3011/// own just-inserted rows are visible to it too, same connection) — not just
3012/// this call's own `messages` — because the caller's own read-position query
3013/// considers EVERY read message in the conversation, including ones a
3014/// PRIOR pass already imported (a message read only after it was already
3015/// bridged must still move the receipt here; manager review, P15). A target
3016/// this catch-up's own `messages` did not just insert AND that is not yet in
3017/// the map at all (should not happen — a receipt only ever targets a message
3018/// that exists) is skipped rather than erroring, same defensive posture
3019/// [`migrate_dm_conversation`]'s own receipt loop takes.
3020pub fn catch_up_dm_conversation(
3021    conn: &mut Connection,
3022    room_id: &str,
3023    messages: &[LegacyDmMessageImport],
3024    receipts: &[LegacyDmReadReceipt],
3025) -> Result<DmMigrationCounts, MatrixStoreError> {
3026    let tx = conn.transaction()?;
3027    let counts = catch_up_dm_in_tx(&tx, room_id, messages, receipts)?;
3028    tx.commit()?;
3029    Ok(counts)
3030}
3031
3032/// The body of [`catch_up_dm_conversation`], inside the caller's transaction —
3033/// shared with [`adopt_dm_room_for_legacy`], so an adopted native room takes
3034/// its legacy messages and read positions through exactly the same code as
3035/// an already-migrated room's catch-up.
3036fn catch_up_dm_in_tx(
3037    tx: &Transaction,
3038    room_id: &str,
3039    messages: &[LegacyDmMessageImport],
3040    receipts: &[LegacyDmReadReceipt],
3041) -> Result<DmMigrationCounts, MatrixStoreError> {
3042    for message in messages {
3043        insert_timeline_event_in_tx(
3044            tx,
3045            &TimelineEventRow {
3046                event_id: &message.event_id,
3047                room_id,
3048                sender_user_id: message.sender_user_id,
3049                event_type: "org.example.legacy_dm",
3050                content: &message.content,
3051                origin_server_ts: message.origin_server_ts,
3052                txn_id: None,
3053            },
3054        )?;
3055        insert_legacy_dm_message_map(tx, message.legacy_message_id, &message.event_id)?;
3056    }
3057
3058    let mut receipts_set = 0usize;
3059    for receipt in receipts {
3060        let Some(target_event_id) = legacy_dm_message_event_id(tx, receipt.up_to_legacy_message_id)? else { continue };
3061        upsert_receipt_in_tx(tx, room_id, receipt.reader_user_id, ReceiptType::Read, &target_event_id, receipt.ts_ms)?;
3062        receipts_set += 1;
3063    }
3064
3065    Ok(DmMigrationCounts { messages_imported: messages.len(), receipts_set })
3066}
3067
3068/// What [`adopt_dm_room_for_legacy`] needs to bind a native DM room to a
3069/// legacy conversation, grouped for the same lint-clean reason
3070/// [`RoomBootstrap`]'s doc states.
3071#[derive(Debug, Clone, Copy)]
3072pub struct DmAdoption<'a> {
3073    /// The live native DM room that already holds the pair's `dm_pair_key`.
3074    pub room_id: &'a str,
3075    /// The legacy `dm_conversations.id` the room is bound to.
3076    pub legacy_dm_id: i64,
3077    /// The pair's `org.example.legacy_dm_key` state events. Each is
3078    /// written only when the room has no current event for its
3079    /// `(event_type, state_key)` yet — an existing key event is never
3080    /// overwritten.
3081    pub key_events: &'a [NewStateEvent],
3082    pub key_events_origin_server_ts: i64,
3083    /// RFC3339 stamp for the member/state bookkeeping of the key events.
3084    pub now: &'a str,
3085}
3086
3087/// Bind an EXISTING native DM room to legacy conversation
3088/// `adoption.legacy_dm_id`, instead of minting a second room for the same
3089/// pair (`rooms.dm_pair_key` is `UNIQUE`, so a second insert is refused):
3090/// sets `rooms.legacy_dm_id`, writes any missing legacy key state event,
3091/// then imports `messages` and `receipts` through the shared catch-up
3092/// body ([`catch_up_dm_in_tx`]) — all in ONE transaction, so a failure
3093/// leaves the room exactly as it was. `messages` follow
3094/// [`catch_up_dm_conversation`]'s contract (not yet in
3095/// `legacy_dm_message_map`, ascending by `legacy_message_id`).
3096///
3097/// Returns `Ok(None)`, having written nothing, when `room_id` is not a DM
3098/// room without a legacy binding (missing, another kind, or already bound to
3099/// a legacy conversation) — the caller decides what that means.
3100pub fn adopt_dm_room_for_legacy(
3101    conn: &mut Connection,
3102    adoption: DmAdoption<'_>,
3103    messages: &[LegacyDmMessageImport],
3104    receipts: &[LegacyDmReadReceipt],
3105) -> Result<Option<DmMigrationCounts>, MatrixStoreError> {
3106    let tx = conn.transaction()?;
3107
3108    let bound = tx.execute(
3109        "UPDATE rooms SET legacy_dm_id = ?1 WHERE id = ?2 AND kind = 'dm' AND legacy_dm_id IS NULL",
3110        params![adoption.legacy_dm_id, adoption.room_id],
3111    )?;
3112    if bound == 0 {
3113        return Ok(None);
3114    }
3115
3116    for event in adoption.key_events {
3117        if current_state_event(&tx, adoption.room_id, &event.event_type, &event.state_key)?.is_some() {
3118            continue;
3119        }
3120        apply_state_event_in_tx(
3121            &tx,
3122            &event.event_id,
3123            adoption.room_id,
3124            event.sender_user_id,
3125            &event.event_type,
3126            &event.state_key,
3127            &event.content,
3128            adoption.key_events_origin_server_ts,
3129            adoption.now,
3130        )?;
3131    }
3132
3133    let counts = catch_up_dm_in_tx(&tx, adoption.room_id, messages, receipts)?;
3134    tx.commit()?;
3135    Ok(Some(counts))
3136}
3137
3138// ============================================================================
3139// Public room directory (P8: `routes::matrix::account`'s `GET`/`POST
3140// /publicRooms`)
3141// ============================================================================
3142
3143/// One row of `GET /publicRooms`'s `chunk` — a public-`join_rule` room's
3144/// directory summary. `name`/`topic` are `None` when the room never had an
3145/// `m.room.name`/`m.room.topic` state event (plan §5: only a `channel`-kind
3146/// room is ever `join_rule = 'public'`, but this reads the column directly
3147/// rather than also filtering on `kind`, so it stays correct even if a
3148/// future piece ever makes a `group` room public).
3149#[derive(Debug, Clone, PartialEq)]
3150pub struct PublicRoomSummary {
3151    pub room_id: String,
3152    pub name: Option<String>,
3153    pub topic: Option<String>,
3154    pub num_joined_members: i64,
3155    pub world_readable: bool,
3156}
3157
3158/// One page of the public-room directory, ordered by `rooms.id` — a stable
3159/// pagination key (a room id never changes once minted). `after_room_id` is
3160/// the previous page's own last room id (`None` for the first page);
3161/// `search_term`, when non-empty, keeps only rooms whose current
3162/// `m.room.name` contains it (case-insensitive substring — Matrix's own
3163/// `generic_search_term`).
3164///
3165/// This scans every public room in Rust rather than pushing the search/
3166/// pagination interaction into SQL: a non-federated single server's public-
3167/// channel count is small and moderator-managed (channels are not something
3168/// a bot mass-creates), so an O(public rooms) scan per call is cheaper to
3169/// keep correct than a SQL query that would otherwise have to paginate
3170/// `LIMIT`-first and then discover a `search_term` filtered a whole page
3171/// down to nothing.
3172///
3173/// Returns `(page, has_more, total_room_count_estimate)` — `has_more` is
3174/// whether another page exists beyond this one (the route layer's own
3175/// `next_batch` decision); `total_room_count_estimate` is the count of every
3176/// public room regardless of `search_term`/pagination, matching the spec's
3177/// "estimate of the total number of public rooms" wording.
3178pub fn public_rooms_page(
3179    conn: &Connection,
3180    after_room_id: Option<&str>,
3181    limit: usize,
3182    search_term: Option<&str>,
3183) -> Result<(Vec<PublicRoomSummary>, bool, i64), MatrixStoreError> {
3184    let mut stmt = conn.prepare(
3185        "SELECT r.id, r.history_visibility,
3186                (SELECT COUNT(*) FROM room_members WHERE room_id = r.id AND membership = 'join')
3187         FROM rooms r WHERE r.join_rule = 'public' ORDER BY r.id ASC",
3188    )?;
3189    let mut rows = stmt.query([])?;
3190    let mut all = Vec::new();
3191    while let Some(row) = rows.next()? {
3192        let room_id: String = row.get(0)?;
3193        let history_visibility_raw: String = row.get(1)?;
3194        let num_joined_members: i64 = row.get(2)?;
3195        let world_readable = history_visibility_raw == HistoryVisibility::WorldReadable.as_str();
3196
3197        let name = current_state_event(conn, &room_id, "m.room.name", "")?
3198            .and_then(|e| serde_json::from_str::<serde_json::Value>(&e.content).ok())
3199            .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string));
3200        let topic = current_state_event(conn, &room_id, "m.room.topic", "")?
3201            .and_then(|e| serde_json::from_str::<serde_json::Value>(&e.content).ok())
3202            .and_then(|v| v.get("topic").and_then(|t| t.as_str()).map(str::to_string));
3203
3204        all.push(PublicRoomSummary { room_id, name, topic, num_joined_members, world_readable });
3205    }
3206    drop(rows);
3207    drop(stmt);
3208
3209    let total_room_count_estimate = all.len() as i64;
3210
3211    let filtered: Vec<PublicRoomSummary> = match search_term {
3212        Some(term) if !term.is_empty() => {
3213            let term_lower = term.to_lowercase();
3214            all.into_iter().filter(|room| room.name.as_deref().is_some_and(|n| n.to_lowercase().contains(&term_lower))).collect()
3215        }
3216        _ => all,
3217    };
3218
3219    let start = match after_room_id {
3220        Some(cursor) => filtered.iter().position(|r| r.room_id == cursor).map_or(0, |idx| idx + 1),
3221        None => 0,
3222    };
3223    let has_more = filtered.len() > start + limit;
3224    let page: Vec<PublicRoomSummary> = filtered.into_iter().skip(start).take(limit).collect();
3225    Ok((page, has_more, total_room_count_estimate))
3226}
3227
3228#[cfg(test)]
3229mod tests {
3230    use super::*;
3231
3232    const T0: &str = "2026-09-24T00:00:00+00:00";
3233    const ROOM: &str = "!testroom:example.org";
3234
3235    fn test_conn() -> Connection {
3236        let conn = Connection::open_in_memory().expect("in-memory sqlite");
3237        create_matrix_schema(&conn).expect("schema");
3238        conn
3239    }
3240
3241    fn ensure_legacy_dm_map_table(conn: &Connection) {
3242        conn.execute_batch(
3243            "CREATE TABLE IF NOT EXISTS legacy_dm_message_map (
3244                legacy_message_id INTEGER PRIMARY KEY,
3245                event_id TEXT NOT NULL UNIQUE REFERENCES events(event_id)
3246            );",
3247        )
3248        .expect("legacy map table for remaining unit tests");
3249    }
3250
3251    fn make_room(conn: &Connection) {
3252        create_room(conn, ROOM, RoomKind::Group, 1, T0, false, JoinRule::Invite, HistoryVisibility::Shared, None, None).expect("create room");
3253    }
3254
3255    // ---- P1 test 1: stream ordering ----
3256
3257    #[test]
3258    fn next_stream_id_is_strictly_monotonic_across_every_table() {
3259        let mut conn = test_conn();
3260        make_room(&conn);
3261
3262        let event = insert_timeline_event(&mut conn, "$event1", ROOM, 1, "m.room.message", "{}", 1000).expect("insert event");
3263        let account_data_stream = upsert_account_data(&mut conn, 1, GLOBAL_ACCOUNT_DATA_ROOM, "m.direct", "{}").expect("account data");
3264        let receipt_stream = upsert_receipt(&mut conn, ROOM, 1, ReceiptType::Read, "$event1", 1500).expect("receipt");
3265
3266        assert!(event.stream_id < account_data_stream, "event {} should precede account data {}", event.stream_id, account_data_stream);
3267        assert!(
3268            account_data_stream < receipt_stream,
3269            "account data {account_data_stream} should precede receipt {receipt_stream}"
3270        );
3271    }
3272
3273    // ---- P1 test 2: current_state replaces, events keeps history ----
3274
3275    #[test]
3276    fn apply_state_event_replaces_current_state_but_keeps_history_in_events() {
3277        let mut conn = test_conn();
3278        make_room(&conn);
3279
3280        apply_state_event(&mut conn, &StateEventWrite { event_id: "$e1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.name", state_key: "", content: r#"{"name":"first"}"#, origin_server_ts: 1000, now: T0 }).expect("apply first");
3281        apply_state_event(&mut conn, &StateEventWrite { event_id: "$e2", room_id: ROOM, sender_user_id: 1, event_type: "m.room.name", state_key: "", content: r#"{"name":"second"}"#, origin_server_ts: 2000, now: T0 }).expect("apply second");
3282
3283        let current = current_state_event(&conn, ROOM, "m.room.name", "").expect("query").expect("row exists");
3284        assert_eq!(current.event_id, "$e2");
3285        assert_eq!(current.content, r#"{"name":"second"}"#);
3286
3287        let e1 = get_event(&conn, "$e1").expect("get e1").expect("row exists");
3288        let e2 = get_event(&conn, "$e2").expect("get e2").expect("row exists");
3289        assert_eq!(e1.content, r#"{"name":"first"}"#);
3290        assert_eq!(e2.content, r#"{"name":"second"}"#);
3291    }
3292
3293    // ---- P1 test 3: v11 redaction allow-list, table-driven ----
3294
3295    #[test]
3296    fn redact_event_strips_content_per_v11_allow_list_by_type() {
3297        let cases: Vec<(&str, &str, serde_json::Value)> = vec![
3298            (
3299                "m.room.member",
3300                r#"{"membership":"join","join_authorised_via_users_server":"@x:example.org","avatar_url":"mxc://x","displayname":"Bob","third_party_invite":{"display_name":"bob@example.com","signed":{"mxid":"@bob:example.org"}}}"#,
3301                serde_json::json!({
3302                    "membership": "join",
3303                    "join_authorised_via_users_server": "@x:example.org",
3304                    "third_party_invite": {"signed": {"mxid": "@bob:example.org"}}
3305                }),
3306            ),
3307            (
3308                "m.room.power_levels",
3309                r#"{"ban":50,"events":{},"events_default":0,"invite":50,"kick":50,"redact":50,"state_default":50,"users":{"@a:x":100},"users_default":0,"extra":"drop me"}"#,
3310                serde_json::json!({
3311                    "ban": 50, "events": {}, "events_default": 0, "invite": 50, "kick": 50,
3312                    "redact": 50, "state_default": 50, "users": {"@a:x": 100}, "users_default": 0
3313                }),
3314            ),
3315            (
3316                "m.room.history_visibility",
3317                r#"{"history_visibility":"shared","extra":"drop"}"#,
3318                serde_json::json!({"history_visibility": "shared"}),
3319            ),
3320            // `m.room.create` is NOT exercised end-to-end here: `redact_event`
3321            // refuses it outright (see `redact_event_refuses_create_and_
3322            // encryption_events`); its "keep everything" allow-list branch
3323            // is covered directly below, against `redact_content_per_v11`.
3324            (
3325                "m.room.message",
3326                r#"{"body":"hi","msgtype":"m.text"}"#,
3327                serde_json::json!({}),
3328            ),
3329        ];
3330
3331        for (event_type, content, expected) in cases {
3332            let mut conn = test_conn();
3333            make_room(&conn);
3334            insert_timeline_event(&mut conn, "$target", ROOM, 1, event_type, content, 1000).expect("insert target");
3335            redact_event(&mut conn, ROOM, "$target", "$redaction", 1, None, 2000).expect("redact");
3336
3337            let target = get_event(&conn, "$target").expect("get target").expect("row exists");
3338            let got: serde_json::Value = serde_json::from_str(&target.content).expect("parse stripped content");
3339            assert_eq!(got, expected, "event_type={event_type}");
3340            assert_eq!(target.redacted_by.as_deref(), Some("$redaction"), "event_type={event_type}");
3341        }
3342    }
3343
3344    #[test]
3345    fn redaction_content_carries_the_target_as_redacts_for_every_write_path() {
3346        let mut conn = test_conn();
3347        make_room(&conn);
3348        insert_timeline_event(&mut conn, "$t1", ROOM, 1, "m.room.message", "{}", 1000).expect("insert t1");
3349        insert_timeline_event(&mut conn, "$t2", ROOM, 1, "m.room.message", "{}", 1100).expect("insert t2");
3350        insert_timeline_event(&mut conn, "$t3", ROOM, 1, "m.room.message", "{}", 1200).expect("insert t3");
3351
3352        let plain = redact_event(&mut conn, ROOM, "$t1", "$r1", 1, None, 2000).expect("plain redact");
3353        assert_eq!(serde_json::from_str::<serde_json::Value>(&plain.content).expect("json"), serde_json::json!({ "redacts": "$t1" }));
3354        assert_eq!(plain.redacts.as_deref(), Some("$t1"));
3355
3356        let marked = redact_event_marked(
3357            &mut conn,
3358            &Redaction { room_id: ROOM, target_event_id: "$t2", redaction_event_id: "$r2", sender_user_id: 1, reason: Some("spam"), origin_server_ts: 2100 },
3359            &serde_json::json!({ "org.example.site_moderation": true }),
3360        )
3361            .expect("marked redact");
3362        assert_eq!(
3363            serde_json::from_str::<serde_json::Value>(&marked.content).expect("json"),
3364            serde_json::json!({ "redacts": "$t2", "reason": "spam", "org.example.site_moderation": true })
3365        );
3366
3367        let deduped = redact_event_deduped(&mut conn, "DEV1", "txn-1", ROOM, "$t3", "$r3", 1, None, 2200, T0).expect("deduped redact");
3368        let DedupedWrite::New(event) = deduped else { panic!("first write is new") };
3369        let stored = get_event(&conn, &event.event_id).expect("get").expect("row exists");
3370        assert_eq!(serde_json::from_str::<serde_json::Value>(&stored.content).expect("json")["redacts"], "$t3");
3371    }
3372
3373    #[test]
3374    fn redact_content_per_v11_keeps_everything_for_m_room_create() {
3375        let content = r#"{"room_version":"11","creator":"@a:example.org"}"#;
3376        let stripped = redact_content_per_v11("m.room.create", content).expect("strip");
3377        let got: serde_json::Value = serde_json::from_str(&stripped).expect("parse");
3378        assert_eq!(got, serde_json::json!({"room_version": "11", "creator": "@a:example.org"}));
3379    }
3380
3381    // ---- P1 test 4: txn dedup ----
3382
3383    #[test]
3384    fn txn_dedup_returns_the_same_event_id_on_a_repeated_txn_id() {
3385        let mut conn = test_conn();
3386        make_room(&conn);
3387
3388        assert_eq!(txn_dedup_lookup(&conn, 1, "DEV1", "txn-1").expect("lookup 1"), TxnDedupEntry::NotSeen);
3389
3390        let event = {
3391            let tx = conn.transaction().expect("tx");
3392            let row = TimelineEventRow {
3393                event_id: "$e1",
3394                room_id: ROOM,
3395                sender_user_id: 1,
3396                event_type: "m.room.message",
3397                content: "{}",
3398                origin_server_ts: 1000,
3399                txn_id: Some("txn-1"),
3400            };
3401            let event = insert_timeline_event_in_tx(&tx, &row).expect("insert");
3402            tx.commit().expect("commit");
3403            event
3404        };
3405        txn_dedup_record(&conn, 1, "DEV1", "txn-1", Some(&event.event_id), T0).expect("record");
3406
3407        assert_eq!(
3408            txn_dedup_lookup(&conn, 1, "DEV1", "txn-1").expect("lookup 2"),
3409            TxnDedupEntry::Seen(Some(event.event_id.clone()))
3410        );
3411
3412        let count: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |row| row.get(0)).expect("count");
3413        assert_eq!(count, 1, "a repeated txn_id must never create a second events row");
3414    }
3415
3416    // ---- P1 test 5: relations survive ciphertext content ----
3417
3418    #[test]
3419    fn relations_index_populated_from_cleartext_relates_to_even_when_content_is_ciphertext() {
3420        let mut conn = test_conn();
3421        make_room(&conn);
3422        insert_timeline_event(&mut conn, "$target", ROOM, 1, "m.room.message", "{}", 1000).expect("target");
3423
3424        let content = r#"{"algorithm":"m.megolm.v1.aes-sha2","ciphertext":"opaque-base64==","sender_key":"opaque","m.relates_to":{"rel_type":"m.annotation","event_id":"$target","key":"a"}}"#;
3425        let reaction = insert_timeline_event(&mut conn, "$reaction", ROOM, 2, "m.room.encrypted", content, 2000).expect("insert reaction");
3426
3427        let (rel_type, target_id, agg_key): (String, String, Option<String>) = conn
3428            .query_row(
3429                "SELECT rel_type, target_id, agg_key FROM relations WHERE event_id = ?1",
3430                params![reaction.event_id],
3431                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3432            )
3433            .expect("relation row exists");
3434        assert_eq!(rel_type, "m.annotation");
3435        assert_eq!(target_id, "$target");
3436        assert_eq!(agg_key.as_deref(), Some("a"));
3437    }
3438
3439    // ---- extra test: global account data is unique ----
3440
3441    #[test]
3442    fn account_data_global_row_is_unique() {
3443        let mut conn = test_conn();
3444        upsert_account_data(&mut conn, 1, GLOBAL_ACCOUNT_DATA_ROOM, "m.direct", r#"{"v":1}"#).expect("first upsert");
3445        upsert_account_data(&mut conn, 1, GLOBAL_ACCOUNT_DATA_ROOM, "m.direct", r#"{"v":2}"#).expect("second upsert");
3446
3447        let count: i64 = conn
3448            .query_row(
3449                "SELECT COUNT(*) FROM account_data WHERE user_id = 1 AND room_id = ''",
3450                [],
3451                |row| row.get(0),
3452            )
3453            .expect("count");
3454        assert_eq!(count, 1, "two global upserts of the same type must leave one row");
3455
3456        let row = get_account_data(&conn, 1, GLOBAL_ACCOUNT_DATA_ROOM, "m.direct").expect("get").expect("row exists");
3457        assert_eq!(row.content, r#"{"v":2}"#);
3458    }
3459
3460    // ---- extra test: receipts never move backwards ----
3461
3462    #[test]
3463    fn receipt_never_moves_backwards() {
3464        let mut conn = test_conn();
3465        make_room(&conn);
3466        let e1 = insert_timeline_event(&mut conn, "$e1", ROOM, 1, "m.room.message", "{}", 1000).expect("e1");
3467        let e2 = insert_timeline_event(&mut conn, "$e2", ROOM, 1, "m.room.message", "{}", 2000).expect("e2");
3468
3469        upsert_receipt(&mut conn, ROOM, 9, ReceiptType::Read, &e2.event_id, 5000).expect("advance to e2");
3470        upsert_receipt(&mut conn, ROOM, 9, ReceiptType::Read, &e1.event_id, 6000).expect("attempted backward move is a no-op");
3471
3472        let receipt = get_receipt(&conn, ROOM, 9, ReceiptType::Read).expect("get").expect("row exists");
3473        assert_eq!(receipt.event_id, e2.event_id, "a receipt must never move back to an earlier event");
3474    }
3475
3476    // ---- P7 tests: notification_count (plan §3.5) ----
3477
3478    #[test]
3479    fn receipt_further_ahead_of_the_two_types_wins_for_notification_count() {
3480        let mut conn = test_conn();
3481        make_room(&conn);
3482        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000001", T0).expect("alice");
3483        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 900, now: T0 }).expect("alice joins");
3484
3485        insert_timeline_event(&mut conn, "$e1", ROOM, 2, "m.room.message", "{}", 1000).expect("e1");
3486        let e2 = insert_timeline_event(&mut conn, "$e2", ROOM, 2, "m.room.message", "{}", 2000).expect("e2");
3487        insert_timeline_event(&mut conn, "$e3", ROOM, 2, "m.room.message", "{}", 3000).expect("e3");
3488        let e4 = insert_timeline_event(&mut conn, "$e4", ROOM, 2, "m.room.message", "{}", 4000).expect("e4");
3489        let e5 = insert_timeline_event(&mut conn, "$e5", ROOM, 2, "m.room.message", "{}", 5000).expect("e5");
3490
3491        // m.read at e2, m.read.private FURTHER AHEAD at e4 — the private
3492        // receipt must win even though "private" has nothing to do with
3493        // ordering: notification_count takes the MAX of the two positions.
3494        upsert_receipt(&mut conn, ROOM, 1, ReceiptType::Read, &e2.event_id, 2500).expect("read receipt");
3495        upsert_receipt(&mut conn, ROOM, 1, ReceiptType::ReadPrivate, &e4.event_id, 4500).expect("private receipt further ahead");
3496
3497        let room = get_room(&conn, ROOM).expect("get room").expect("room exists");
3498        assert_eq!(notification_count(&conn, &room, 1).expect("count"), 1, "only $e5 is after the further-ahead receipt ($e4)");
3499
3500        // Reverse: m.read now further ahead than m.read.private — m.read
3501        // must win this time.
3502        upsert_receipt(&mut conn, ROOM, 1, ReceiptType::Read, &e5.event_id, 5500).expect("read receipt advances past e5");
3503        assert_eq!(notification_count(&conn, &room, 1).expect("count"), 0, "m.read now covers every message");
3504    }
3505
3506    #[test]
3507    fn notification_count_ignores_own_state_and_reactions() {
3508        let mut conn = test_conn();
3509        make_room(&conn);
3510        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000001", T0).expect("alice");
3511        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 900, now: T0 }).expect("alice joins");
3512
3513        // Bob's message-like sends — these count.
3514        insert_timeline_event(&mut conn, "$bob-msg", ROOM, 2, "m.room.message", "{}", 1000).expect("bob message");
3515        insert_timeline_event(&mut conn, "$bob-enc", ROOM, 2, "m.room.encrypted", "{}", 1100).expect("bob encrypted");
3516        insert_timeline_event(&mut conn, "$bob-legacy", ROOM, 2, "org.example.legacy_dm", "{}", 1200).expect("bob legacy dm");
3517
3518        // Excluded: alice's own message, a state event, and a reaction.
3519        insert_timeline_event(&mut conn, "$alice-msg", ROOM, 1, "m.room.message", "{}", 1300).expect("alice's own message");
3520        apply_state_event(&mut conn, &StateEventWrite { event_id: "$name", room_id: ROOM, sender_user_id: 2, event_type: "m.room.name", state_key: "", content: r#"{"name":"x"}"#, origin_server_ts: 1400, now: T0 }).expect("state event");
3521        insert_timeline_event(&mut conn, "$reaction", ROOM, 2, "m.reaction", "{}", 1500).expect("reaction");
3522
3523        let room = get_room(&conn, ROOM).expect("get room").expect("room exists");
3524        assert_eq!(notification_count(&conn, &room, 1).expect("count"), 3, "only bob's 3 message-like sends count");
3525    }
3526
3527    #[test]
3528    fn notification_count_is_zero_for_a_non_member() {
3529        let mut conn = test_conn();
3530        make_room(&conn);
3531        insert_timeline_event(&mut conn, "$e1", ROOM, 2, "m.room.message", "{}", 1000).expect("e1");
3532
3533        let room = get_room(&conn, ROOM).expect("get room").expect("room exists");
3534        assert_eq!(notification_count(&conn, &room, 999).expect("count"), 0, "a caller with no membership row at all sees nothing");
3535    }
3536
3537    // ---- extra test: duplicate annotation refused ----
3538
3539    #[test]
3540    fn duplicate_annotation_is_refused() {
3541        let mut conn = test_conn();
3542        make_room(&conn);
3543        insert_timeline_event(&mut conn, "$target", ROOM, 1, "m.room.message", "{}", 1000).expect("target");
3544
3545        let content = r#"{"m.relates_to":{"rel_type":"m.annotation","event_id":"$target","key":"a"}}"#;
3546        insert_timeline_event(&mut conn, "$react1", ROOM, 5, "m.reaction", content, 2000).expect("first reaction");
3547
3548        let err = insert_timeline_event(&mut conn, "$react2", ROOM, 5, "m.reaction", content, 3000).unwrap_err();
3549        assert!(matches!(err, MatrixStoreError::DuplicateAnnotation));
3550
3551        // The whole transaction must have rolled back — no partial events row.
3552        assert_eq!(get_event(&conn, "$react2").expect("get"), None);
3553
3554        // A DIFFERENT sender reacting with the same key is a distinct,
3555        // accepted annotation (not covered by the refusal).
3556        let other = insert_timeline_event(&mut conn, "$react3", ROOM, 6, "m.reaction", content, 4000).expect("different sender reacts");
3557        assert_eq!(other.event_id, "$react3");
3558    }
3559
3560    // ---- manager-review fixes (2026-09-24 follow-up) ----
3561
3562    #[test]
3563    fn reannotation_after_redaction_is_allowed() {
3564        let mut conn = test_conn();
3565        make_room(&conn);
3566        insert_timeline_event(&mut conn, "$target", ROOM, 1, "m.room.message", "{}", 1000).expect("target");
3567
3568        let content = r#"{"m.relates_to":{"rel_type":"m.annotation","event_id":"$target","key":"a"}}"#;
3569        let first = insert_timeline_event(&mut conn, "$react1", ROOM, 5, "m.reaction", content, 2000).expect("first reaction");
3570
3571        redact_event(&mut conn, ROOM, &first.event_id, "$redaction", 1, None, 2500).expect("redact the reaction");
3572
3573        // The same sender may now react with the same key again — the
3574        // redacted prior annotation no longer counts as a duplicate.
3575        let second = insert_timeline_event(&mut conn, "$react2", ROOM, 5, "m.reaction", content, 3000)
3576            .expect("re-annotation after redaction must succeed");
3577        assert_eq!(second.event_id, "$react2");
3578    }
3579
3580    #[test]
3581    fn relation_target_must_exist() {
3582        let mut conn = test_conn();
3583        make_room(&conn);
3584
3585        let content = r#"{"m.relates_to":{"rel_type":"m.annotation","event_id":"$missing","key":"a"}}"#;
3586        let err = insert_timeline_event(&mut conn, "$react1", ROOM, 5, "m.reaction", content, 2000).unwrap_err();
3587        assert!(matches!(err, MatrixStoreError::InvalidRelationTarget(ref id) if id == "$missing"));
3588    }
3589
3590    #[test]
3591    fn relation_target_must_be_in_the_same_room() {
3592        let mut conn = test_conn();
3593        make_room(&conn);
3594        let other_room = format!("!other:{}", matrix_server_name());
3595        create_room(&conn, &other_room, RoomKind::Group, 1, T0, false, JoinRule::Invite, HistoryVisibility::Shared, None, None)
3596            .expect("other room");
3597        insert_timeline_event(&mut conn, "$target", &other_room, 1, "m.room.message", "{}", 1000).expect("target in other room");
3598
3599        let content = r#"{"m.relates_to":{"rel_type":"m.annotation","event_id":"$target","key":"a"}}"#;
3600        let err = insert_timeline_event(&mut conn, "$react1", ROOM, 5, "m.reaction", content, 2000).unwrap_err();
3601        assert!(matches!(err, MatrixStoreError::InvalidRelationTarget(ref id) if id == "$target"));
3602    }
3603
3604    #[test]
3605    fn redact_event_refuses_a_target_in_a_different_room() {
3606        let mut conn = test_conn();
3607        make_room(&conn);
3608        let other_room = format!("!other:{}", matrix_server_name());
3609        create_room(&conn, &other_room, RoomKind::Group, 1, T0, false, JoinRule::Invite, HistoryVisibility::Shared, None, None)
3610            .expect("other room");
3611        insert_timeline_event(&mut conn, "$target", &other_room, 1, "m.room.message", "{}", 1000).expect("target in other room");
3612
3613        let err = redact_event(&mut conn, ROOM, "$target", "$redaction", 1, None, 2000).unwrap_err();
3614        assert!(matches!(err, MatrixStoreError::WrongRoom(ref id) if id == "$target"));
3615    }
3616
3617    #[test]
3618    fn redact_event_refuses_create_and_encryption_events() {
3619        for event_type in ["m.room.create", "m.room.encryption"] {
3620            let mut conn = test_conn();
3621            make_room(&conn);
3622            apply_state_event(&mut conn, &StateEventWrite { event_id: "$target", room_id: ROOM, sender_user_id: 1, event_type, state_key: "", content: r#"{"a":1}"#, origin_server_ts: 1000, now: T0 }).expect("apply state event");
3623
3624            let err = redact_event(&mut conn, ROOM, "$target", "$redaction", 1, None, 2000).unwrap_err();
3625            assert!(
3626                matches!(err, MatrixStoreError::UnredactableEvent(ref t) if t == event_type),
3627                "event_type={event_type}"
3628            );
3629        }
3630    }
3631
3632    #[test]
3633    fn upsert_receipt_refuses_a_target_in_a_different_room() {
3634        let mut conn = test_conn();
3635        make_room(&conn);
3636        let other_room = format!("!other:{}", matrix_server_name());
3637        create_room(&conn, &other_room, RoomKind::Group, 1, T0, false, JoinRule::Invite, HistoryVisibility::Shared, None, None)
3638            .expect("other room");
3639        let event = insert_timeline_event(&mut conn, "$e1", &other_room, 1, "m.room.message", "{}", 1000).expect("event in other room");
3640
3641        let err = upsert_receipt(&mut conn, ROOM, 9, ReceiptType::Read, &event.event_id, 5000).unwrap_err();
3642        assert!(matches!(err, MatrixStoreError::WrongRoom(ref id) if id == &event.event_id));
3643    }
3644
3645    // ---- extra test: mxid parsing refuses a foreign server ----
3646
3647    #[test]
3648    fn mxid_parse_refuses_foreign_server() {
3649        assert_eq!(public_id_from_mxid("@abc123:example.org"), Ok("abc123"));
3650        assert_eq!(public_id_from_mxid("@abc123:otherserver.example"), Err(MatrixIdError::ForeignServerName));
3651        // Local aliases: server-name check only (enabled once per process).
3652        let aliases = ["chat.example", "m4a.example.net", "m4a.example.org"];
3653        set_local_aliases(aliases.iter().map(|s| s.to_string()));
3654        for name in aliases {
3655            assert_eq!(public_id_from_mxid(&format!("@abc123:{name}")), Ok("abc123"));
3656        }
3657        assert_eq!(public_id_from_mxid("@abc123:evil.example"), Err(MatrixIdError::ForeignServerName));
3658        assert_eq!(mxid_for_public_id("abc123"), format!("@abc123:{}", matrix_server_name()), "minting never uses an alias");
3659        assert_eq!(public_id_from_mxid("abc123:example.org"), Err(MatrixIdError::MissingSigil));
3660        assert_eq!(public_id_from_mxid("@abc123"), Err(MatrixIdError::MissingServerName));
3661    }
3662
3663    // ---- supporting coverage for the CRUD surface the tests above don't already exercise ----
3664
3665    #[test]
3666    fn ensure_matrix_user_is_idempotent_and_refuses_a_reserved_localpart() {
3667        let conn = test_conn();
3668        let mxid = ensure_matrix_user(&conn, 1, "abc123", T0).expect("first ensure");
3669        assert_eq!(mxid, "@abc123:example.org");
3670        let mxid_again = ensure_matrix_user(&conn, 1, "abc123", T0).expect("second ensure is a no-op");
3671        assert_eq!(mxid_again, mxid);
3672        assert_eq!(mxid_of(&conn, 1).expect("mxid_of"), Some(mxid.clone()));
3673        assert_eq!(user_id_of(&conn, &mxid).expect("user_id_of"), Some(1));
3674
3675        let err = ensure_matrix_user(&conn, 2, "_bridge_evil", T0).unwrap_err();
3676        assert!(matches!(err, MatrixStoreError::ReservedLocalpart));
3677    }
3678
3679    #[test]
3680    fn apply_state_event_member_refreshes_room_members_and_power_levels() {
3681        let mut conn = test_conn();
3682        make_room(&conn);
3683        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000001", T0).expect("alice");
3684        let bob = ensure_matrix_user(&conn, 2, "bob000000000000000000000000002", T0).expect("bob");
3685
3686        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 1000, now: T0 }).expect("alice joins");
3687        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m2", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"invite"}"#, origin_server_ts: 1100, now: T0 }).expect("bob invited");
3688
3689        let joined = room_members(&conn, ROOM, Some(Membership::Join)).expect("joined members");
3690        assert_eq!(joined.len(), 1);
3691        assert_eq!(joined[0].user_id, 1);
3692        assert_eq!(joined[0].power_level, None);
3693
3694        let power_levels_content = serde_json::json!({"users": {alice.clone(): 100}, "users_default": 0}).to_string();
3695        apply_state_event(&mut conn, &StateEventWrite { event_id: "$pl1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.power_levels", state_key: "", content: &power_levels_content, origin_server_ts: 1200, now: T0 }).expect("power levels");
3696
3697        let alice_row = room_members(&conn, ROOM, None)
3698            .expect("all members")
3699            .into_iter()
3700            .find(|m| m.user_id == 1)
3701            .expect("alice row");
3702        assert_eq!(alice_row.power_level, Some(100));
3703
3704        let bob_rooms = rooms_for_user(&conn, 2, Some(Membership::Invite)).expect("bob's invited rooms");
3705        assert_eq!(bob_rooms, vec![ROOM.to_string()]);
3706    }
3707
3708    #[test]
3709    fn events_in_room_after_and_before_page_in_the_documented_order() {
3710        let mut conn = test_conn();
3711        make_room(&conn);
3712        let e1 = insert_timeline_event(&mut conn, "$e1", ROOM, 1, "m.room.message", "{}", 1000).expect("e1");
3713        let e2 = insert_timeline_event(&mut conn, "$e2", ROOM, 1, "m.room.message", "{}", 2000).expect("e2");
3714        let e3 = insert_timeline_event(&mut conn, "$e3", ROOM, 1, "m.room.message", "{}", 3000).expect("e3");
3715
3716        let after = events_in_room_after(&conn, ROOM, e1.stream_id, 10).expect("after");
3717        assert_eq!(after.iter().map(|e| e.event_id.clone()).collect::<Vec<_>>(), vec![e2.event_id.clone(), e3.event_id.clone()]);
3718
3719        let before = events_in_room_before(&conn, ROOM, e3.stream_id, 10).expect("before");
3720        assert_eq!(before.iter().map(|e| e.event_id.clone()).collect::<Vec<_>>(), vec![e2.event_id.clone(), e1.event_id.clone()]);
3721
3722        assert_eq!(max_stream_id(&conn).expect("max"), e3.stream_id);
3723    }
3724
3725    #[test]
3726    fn txn_dedup_lookup_of_a_to_device_send_has_no_event_id() {
3727        let conn = test_conn();
3728        assert_eq!(txn_dedup_lookup(&conn, 1, "DEV1", "txn-td").expect("lookup"), TxnDedupEntry::NotSeen);
3729        txn_dedup_record(&conn, 1, "DEV1", "txn-td", None, T0).expect("record to-device send");
3730        assert_eq!(txn_dedup_lookup(&conn, 1, "DEV1", "txn-td").expect("lookup again"), TxnDedupEntry::Seen(None));
3731    }
3732
3733    #[test]
3734    fn filters_create_and_get_are_scoped_to_their_owner() {
3735        let conn = test_conn();
3736        let filter_id = create_filter(&conn, 1, r#"{"room":{"timeline":{"limit":20}}}"#).expect("create");
3737        assert_eq!(get_filter(&conn, 1, filter_id).expect("owner reads it"), Some(r#"{"room":{"timeline":{"limit":20}}}"#.to_string()));
3738        assert_eq!(get_filter(&conn, 2, filter_id).expect("a different user cannot"), None);
3739    }
3740
3741    #[test]
3742    fn legacy_dm_message_map_insert_and_get_round_trip() {
3743        let mut conn = test_conn();
3744        make_room(&conn);
3745        ensure_legacy_dm_map_table(&conn);
3746        let event = insert_timeline_event(&mut conn, "$legacy1", ROOM, 1, "org.example.legacy_dm", "{}", 1000).expect("insert");
3747        insert_legacy_dm_message_map(&conn, 42, &event.event_id).expect("map insert");
3748        assert_eq!(legacy_dm_message_event_id(&conn, 42).expect("map get"), Some(event.event_id));
3749        assert_eq!(legacy_dm_message_event_id(&conn, 999).expect("missing"), None);
3750    }
3751
3752    // ---- P4 test: power-level defaults ----
3753
3754    #[test]
3755    fn power_level_defaults_apply_when_fields_missing() {
3756        let pl = serde_json::json!({});
3757        assert_eq!(user_level(&pl, "@nobody:example.org"), 0, "users_default defaults to 0");
3758        assert_eq!(event_level(&pl, "m.room.message", false), 0, "events_default defaults to 0");
3759        assert_eq!(event_level(&pl, "m.room.name", true), 50, "state_default defaults to 50");
3760        for action in [PowerAction::Invite, PowerAction::Kick, PowerAction::Ban, PowerAction::Redact, PowerAction::StateDefault] {
3761            assert!(!can(&pl, action, "@nobody:example.org"), "level 0 must not reach the default 50 threshold for {action:?}");
3762        }
3763
3764        let pl_with_creator = serde_json::json!({ "users": { "@creator:example.org": 100 } });
3765        assert!(can(&pl_with_creator, PowerAction::Ban, "@creator:example.org"));
3766        assert!(can(&pl_with_creator, PowerAction::StateDefault, "@creator:example.org"));
3767    }
3768
3769    #[test]
3770    fn event_level_uses_the_events_type_override_before_falling_back_to_a_default() {
3771        let pl = serde_json::json!({ "events": { "m.room.name": 60 }, "events_default": 0, "state_default": 50 });
3772        assert_eq!(event_level(&pl, "m.room.name", true), 60, "an explicit events[type] override wins");
3773        assert_eq!(event_level(&pl, "m.room.topic", true), 50, "an unlisted state type falls back to state_default");
3774        assert_eq!(event_level(&pl, "m.room.message", false), 0, "an unlisted timeline type falls back to events_default");
3775    }
3776
3777    // ---- manager review 2026-09-24: can_act_on / validate_power_levels_change ----
3778
3779    #[test]
3780    fn can_act_on_requires_strictly_greater_level_except_self_leave() {
3781        let pl = serde_json::json!({
3782            "users": { "@owner:example.org": 100, "@admin:example.org": 50, "@peer:example.org": 50 },
3783            "kick": 50,
3784            "ban": 50,
3785        });
3786
3787        // A level-50 admin reaches the flat kick threshold (50) but must
3788        // NOT be able to kick the level-100 owner.
3789        assert!(!can_act_on(&pl, PowerAction::Kick, "@admin:example.org", "@owner:example.org", false));
3790        // Nor an equal-level peer.
3791        assert!(!can_act_on(&pl, PowerAction::Kick, "@admin:example.org", "@peer:example.org", false));
3792        // The owner CAN kick the admin (100 > 50).
3793        assert!(can_act_on(&pl, PowerAction::Kick, "@owner:example.org", "@admin:example.org", false));
3794        // A member may always leave (reject an invite / self-kick) regardless
3795        // of level, when self_leave is set and sender == target.
3796        assert!(can_act_on(&pl, PowerAction::Kick, "@admin:example.org", "@admin:example.org", true));
3797        // ...but NOT when the action does not actually result in a leave.
3798        assert!(!can_act_on(&pl, PowerAction::Ban, "@admin:example.org", "@admin:example.org", false));
3799    }
3800
3801    #[test]
3802    fn validate_power_levels_change_refuses_raising_self_above_own_level() {
3803        let old = serde_json::json!({ "users": { "@admin:example.org": 50 } });
3804        let new = serde_json::json!({ "users": { "@admin:example.org": 100 } });
3805        assert!(validate_power_levels_change(&old, &new, "@admin:example.org").is_err());
3806    }
3807
3808    #[test]
3809    fn validate_power_levels_change_refuses_demoting_a_peer_at_an_equal_level() {
3810        let old = serde_json::json!({ "users": { "@a:example.org": 50, "@b:example.org": 50 } });
3811        let new = serde_json::json!({ "users": { "@a:example.org": 50, "@b:example.org": 0 } });
3812        assert!(validate_power_levels_change(&old, &new, "@a:example.org").is_err());
3813    }
3814
3815    #[test]
3816    fn validate_power_levels_change_allows_demoting_self() {
3817        let old = serde_json::json!({ "users": { "@admin:example.org": 50 } });
3818        let new = serde_json::json!({ "users": { "@admin:example.org": 10 } });
3819        assert!(validate_power_levels_change(&old, &new, "@admin:example.org").is_ok());
3820    }
3821
3822    #[test]
3823    fn validate_power_levels_change_refuses_raising_events_default_above_own_level() {
3824        let old = serde_json::json!({ "users": { "@admin:example.org": 50 }, "events_default": 0 });
3825        let new = serde_json::json!({ "users": { "@admin:example.org": 50 }, "events_default": 60 });
3826        assert!(validate_power_levels_change(&old, &new, "@admin:example.org").is_err());
3827    }
3828
3829    #[test]
3830    fn validate_power_levels_change_allows_the_owner_changing_anything_up_to_their_own_level() {
3831        let old = serde_json::json!({ "users": { "@owner:example.org": 100, "@a:example.org": 50 } });
3832        let new = serde_json::json!({
3833            "users": { "@owner:example.org": 100, "@a:example.org": 90 },
3834            "ban": 100,
3835            "kick": 100,
3836            "events_default": 100,
3837        });
3838        assert!(validate_power_levels_change(&old, &new, "@owner:example.org").is_ok());
3839    }
3840
3841    #[test]
3842    fn validate_power_levels_change_refuses_a_scalar_field_change_above_own_level() {
3843        let old = serde_json::json!({ "users": { "@admin:example.org": 50 }, "ban": 50 });
3844        let new = serde_json::json!({ "users": { "@admin:example.org": 50 }, "ban": 75 });
3845        assert!(validate_power_levels_change(&old, &new, "@admin:example.org").is_err());
3846    }
3847
3848    #[test]
3849    fn validate_power_levels_change_ignores_unchanged_fields() {
3850        let old = serde_json::json!({ "users": { "@admin:example.org": 50 }, "ban": 50, "events": { "m.room.name": 40 } });
3851        let new = old.clone();
3852        assert!(validate_power_levels_change(&old, &new, "@admin:example.org").is_ok());
3853    }
3854
3855    // ---- P5 test: create_room_with_state is one transaction ----
3856
3857    fn bootstrap<'a>(room_id: &'a str, kind: RoomKind, creator: i64) -> RoomBootstrap<'a> {
3858        RoomBootstrap {
3859            room_id,
3860            kind,
3861            creator_user_id: creator,
3862            created_at: T0,
3863            is_encrypted: true,
3864            join_rule: if kind == RoomKind::Channel { JoinRule::Public } else { JoinRule::Invite },
3865            history_visibility: HistoryVisibility::Shared,
3866            dm_pair_key: None,
3867            legacy_dm_id: None,
3868        }
3869    }
3870
3871    fn state_event(event_id: &str, sender: i64, event_type: &str, state_key: &str, content: &str) -> NewStateEvent {
3872        NewStateEvent {
3873            event_id: event_id.to_string(),
3874            sender_user_id: sender,
3875            event_type: event_type.to_string(),
3876            state_key: state_key.to_string(),
3877            content: content.to_string(),
3878        }
3879    }
3880
3881    #[test]
3882    fn create_room_with_state_inserts_the_room_and_every_bootstrap_event_atomically() {
3883        let mut conn = test_conn();
3884        let creator_mxid = ensure_matrix_user(&conn, 1, "creator0000000000000000000001", T0).expect("creator");
3885        let room_id = "!batch:example.org";
3886
3887        let events = vec![
3888            state_event("$create", 1, "m.room.create", "", r#"{"room_version":"11"}"#),
3889            state_event("$m1", 1, "m.room.member", &creator_mxid, r#"{"membership":"join"}"#),
3890            state_event(
3891                "$pl",
3892                1,
3893                "m.room.power_levels",
3894                "",
3895                &serde_json::json!({"users": {creator_mxid.clone(): 100}, "users_default": 0}).to_string(),
3896            ),
3897        ];
3898
3899        let (room, applied) = create_room_with_state(&mut conn, bootstrap(room_id, RoomKind::Group, 1), &events, 1000).expect("create batch");
3900        assert_eq!(room.id, room_id);
3901        assert_eq!(applied.len(), 3);
3902        assert!(get_room(&conn, room_id).expect("get room").is_some());
3903        for event in &applied {
3904            assert!(get_event(&conn, &event.event_id).expect("get event").is_some());
3905        }
3906        let creator_row = room_member(&conn, room_id, 1).expect("member row").expect("row exists");
3907        assert_eq!(creator_row.power_level, Some(100), "power_levels applied after the member row existed");
3908    }
3909
3910    #[test]
3911    fn create_room_is_atomic_on_failure() {
3912        let mut conn = test_conn();
3913        let creator_mxid = ensure_matrix_user(&conn, 1, "creator0000000000000000000002", T0).expect("creator");
3914        let room_id = "!atomic:example.org";
3915
3916        let events = vec![
3917            state_event("$create", 1, "m.room.create", "", r#"{"room_version":"11"}"#),
3918            state_event("$m1", 1, "m.room.member", &creator_mxid, r#"{"membership":"join"}"#),
3919            // Never `ensure_matrix_user`'d — `refresh_room_member` must
3920            // refuse this with `UnknownMxid`, rolling back the WHOLE batch.
3921            state_event("$bad", 1, "m.room.member", "@ghost:example.org", r#"{"membership":"invite"}"#),
3922        ];
3923
3924        let err = create_room_with_state(&mut conn, bootstrap(room_id, RoomKind::Group, 1), &events, 1000).unwrap_err();
3925        assert!(matches!(err, MatrixStoreError::UnknownMxid(ref m) if m == "@ghost:example.org"));
3926
3927        assert_eq!(get_room(&conn, room_id).expect("get room"), None, "a failed batch must leave no room row");
3928        assert_eq!(get_event(&conn, "$create").expect("get"), None, "a failed batch must leave no event rows at all");
3929        assert_eq!(get_event(&conn, "$m1").expect("get"), None);
3930    }
3931
3932    // ---- P5 test: DM pair-key reuse frees a dead pair's slot ----
3933
3934    #[test]
3935    fn dm_pair_key_reuse_is_freed_once_the_room_is_not_reused() {
3936        let conn = test_conn();
3937        create_room(&conn, "!dm1:example.org", RoomKind::Dm, 1, T0, true, JoinRule::Invite, HistoryVisibility::Shared, Some("1:2"), None)
3938            .expect("first dm room");
3939        assert_eq!(room_by_dm_pair_key(&conn, "1:2").expect("lookup").map(|r| r.id), Some("!dm1:example.org".to_string()));
3940
3941        clear_dm_pair_key(&conn, "!dm1:example.org").expect("clear");
3942        assert_eq!(room_by_dm_pair_key(&conn, "1:2").expect("lookup after clear"), None);
3943
3944        // The freed pair key can now be claimed by a second room.
3945        create_room(&conn, "!dm2:example.org", RoomKind::Dm, 1, T0, true, JoinRule::Invite, HistoryVisibility::Shared, Some("1:2"), None)
3946            .expect("second dm room reuses the freed pair key");
3947        assert_eq!(room_by_dm_pair_key(&conn, "1:2").expect("lookup").map(|r| r.id), Some("!dm2:example.org".to_string()));
3948    }
3949
3950    // ---- P5 test: room_member / forget_membership ----
3951
3952    #[test]
3953    fn room_member_finds_the_one_row_forget_membership_deletes_only_when_left() {
3954        let mut conn = test_conn();
3955        make_room(&conn);
3956        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000099", T0).expect("alice");
3957        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 1000, now: T0 }).expect("join");
3958
3959        assert_eq!(room_member(&conn, ROOM, 1).expect("member").map(|m| m.membership), Some(Membership::Join));
3960        assert_eq!(room_member(&conn, ROOM, 999).expect("no such member"), None);
3961
3962        // Still joined — forget must refuse (0 rows deleted), matching the
3963        // route-layer gate "Member with membership='leave'".
3964        assert_eq!(forget_membership(&conn, ROOM, 1).expect("forget while joined"), 0);
3965        assert!(room_member(&conn, ROOM, 1).expect("still a member").is_some());
3966
3967        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m2", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"leave"}"#, origin_server_ts: 2000, now: T0 }).expect("leave");
3968        assert_eq!(forget_membership(&conn, ROOM, 1).expect("forget after leaving"), 1);
3969        assert_eq!(room_member(&conn, ROOM, 1).expect("gone"), None);
3970    }
3971
3972    // ---- P5 test: state_events_of_type_at reconstructs a point-in-time projection ----
3973
3974    #[test]
3975    fn state_events_of_type_at_excludes_state_keys_created_after_the_cutoff() {
3976        let mut conn = test_conn();
3977        make_room(&conn);
3978        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000098", T0).expect("alice");
3979        let bob = ensure_matrix_user(&conn, 2, "bob0000000000000000000000000098", T0).expect("bob");
3980
3981        let e1 = apply_state_event(&mut conn, &StateEventWrite { event_id: "$m1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 1000, now: T0 }).expect("alice joins");
3982        let cutoff = e1.stream_id;
3983        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m2", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"invite"}"#, origin_server_ts: 1100, now: T0 }).expect("bob invited later");
3984
3985        let at_cutoff = state_events_of_type_at(&conn, ROOM, "m.room.member", cutoff).expect("at cutoff");
3986        assert_eq!(at_cutoff.len(), 1, "bob's invite lands strictly after the cutoff and must be excluded");
3987        assert_eq!(at_cutoff[0].event_id, "$m1");
3988
3989        let after_both = state_events_of_type_at(&conn, ROOM, "m.room.member", cutoff + 1).expect("after both");
3990        assert_eq!(after_both.len(), 2);
3991    }
3992
3993    // ---- P5 test: stripped_invite_state ----
3994
3995    #[test]
3996    fn stripped_invite_state_includes_room_basics_and_the_inviters_own_member_event() {
3997        let mut conn = test_conn();
3998        make_room(&conn);
3999        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000097", T0).expect("alice");
4000        let bob = ensure_matrix_user(&conn, 2, "bob0000000000000000000000000097", T0).expect("bob");
4001
4002        apply_state_event(&mut conn, &StateEventWrite { event_id: "$create", room_id: ROOM, sender_user_id: 1, event_type: "m.room.create", state_key: "", content: r#"{"room_version":"11"}"#, origin_server_ts: 900, now: T0 }).expect("create");
4003        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 1000, now: T0 }).expect("alice joins");
4004        apply_state_event(&mut conn, &StateEventWrite { event_id: "$jr", room_id: ROOM, sender_user_id: 1, event_type: "m.room.join_rules", state_key: "", content: r#"{"join_rule":"invite"}"#, origin_server_ts: 1100, now: T0 }).expect("join rules");
4005        apply_state_event(&mut conn, &StateEventWrite { event_id: "$m2", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"invite"}"#, origin_server_ts: 1200, now: T0 }).expect("bob invited");
4006
4007        let stripped = stripped_invite_state(&conn, ROOM, 1).expect("stripped state");
4008        let types: Vec<&str> = stripped.iter().map(|v| v["type"].as_str().expect("type")).collect();
4009        assert!(types.contains(&"m.room.create"));
4010        assert!(types.contains(&"m.room.join_rules"));
4011        assert!(!types.contains(&"m.room.encryption"), "no encryption event exists in this room");
4012
4013        let inviter_member = stripped
4014            .iter()
4015            .find(|v| v["type"] == "m.room.member" && v["state_key"] == alice)
4016            .expect("the inviter's own member event is included");
4017        assert_eq!(inviter_member["sender"], alice);
4018        assert_eq!(inviter_member["content"]["membership"], "join");
4019    }
4020
4021    // ---- P9 support: user_ids_with_leave_transition_in_rooms ----
4022
4023    #[test]
4024    fn user_ids_with_leave_transition_in_rooms_finds_only_leave_and_ban_inside_the_window() {
4025        let mut conn = test_conn();
4026        make_room(&conn);
4027        let alice = ensure_matrix_user(&conn, 2, "alice00000000000000000000000097", T0).expect("alice");
4028        let bob = ensure_matrix_user(&conn, 3, "bob0000000000000000000000000097", T0).expect("bob");
4029        let carol = ensure_matrix_user(&conn, 4, "carol0000000000000000000000097a", T0).expect("carol");
4030
4031        apply_state_event(&mut conn, &StateEventWrite { event_id: "$a1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 1000, now: T0 }).expect("alice joins");
4032        let boundary = apply_state_event(&mut conn, &StateEventWrite { event_id: "$b1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"join"}"#, origin_server_ts: 1100, now: T0 })
4033            .expect("bob joins")
4034            .stream_id;
4035        apply_state_event(&mut conn, &StateEventWrite { event_id: "$a2", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"leave"}"#, origin_server_ts: 1200, now: T0 }).expect("alice leaves");
4036        apply_state_event(&mut conn, &StateEventWrite { event_id: "$b2", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"ban"}"#, origin_server_ts: 1300, now: T0 }).expect("bob banned");
4037        apply_state_event(&mut conn, &StateEventWrite { event_id: "$c1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &carol, content: r#"{"membership":"join"}"#, origin_server_ts: 1400, now: T0 }).expect("carol joins (not a departure)");
4038
4039        let mut left = user_ids_with_leave_transition_in_rooms(&conn, &[ROOM.to_string()], boundary, i64::MAX).expect("query");
4040        left.sort_unstable();
4041        assert_eq!(left, vec![2, 3], "alice (leave) and bob (ban) both count; carol's join does not");
4042
4043        let empty = user_ids_with_leave_transition_in_rooms(&conn, &[], 0, i64::MAX).expect("empty room set");
4044        assert!(empty.is_empty());
4045    }
4046
4047    // ---- P16 S-f: point-in-time membership and the window's member candidates ----
4048
4049    #[test]
4050    fn membership_at_reads_the_state_as_of_a_stream_position() {
4051        let mut conn = test_conn();
4052        make_room(&conn);
4053        let alice = ensure_matrix_user(&conn, 2, "alice00000000000000000000000081", T0).expect("alice");
4054        let bob = ensure_matrix_user(&conn, 3, "bob0000000000000000000000000081", T0).expect("bob");
4055
4056        let alice_joined = apply_state_event(&mut conn, &StateEventWrite { event_id: "$a1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 1000, now: T0 }).expect("alice joins").stream_id;
4057        let bob_invited = apply_state_event(&mut conn, &StateEventWrite { event_id: "$b1", room_id: ROOM, sender_user_id: 1, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"invite"}"#, origin_server_ts: 1100, now: T0 }).expect("bob invited").stream_id;
4058        let bob_joined = apply_state_event(&mut conn, &StateEventWrite { event_id: "$b2", room_id: ROOM, sender_user_id: 3, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"join","displayname":"Bob"}"#, origin_server_ts: 1200, now: T0 }).expect("bob joins").stream_id;
4059
4060        assert_eq!(membership_at(&conn, ROOM, &bob, alice_joined).expect("query"), None, "bob had no member event yet");
4061        assert_eq!(membership_at(&conn, ROOM, &bob, bob_invited).expect("query"), Some(Membership::Invite));
4062        assert_eq!(membership_at(&conn, ROOM, &bob, bob_joined).expect("query"), Some(Membership::Join));
4063        assert_eq!(membership_at(&conn, ROOM, &alice, bob_invited).expect("query"), Some(Membership::Join));
4064
4065        let mut window = member_state_keys_in_window(&conn, ROOM, bob_invited, bob_joined).expect("window");
4066        window.sort();
4067        assert_eq!(window, vec![bob.clone()], "only bob has a member event after `bob_invited`");
4068        let mut all = member_state_keys_in_window(&conn, ROOM, 0, bob_joined).expect("whole history");
4069        all.sort();
4070        let mut expected = vec![alice, bob];
4071        expected.sort();
4072        assert_eq!(all, expected);
4073
4074        let rooms = [ROOM.to_string(), "!other:example.org".to_string()];
4075        assert_eq!(rooms_with_member_events_in_window(&conn, &rooms, bob_invited, bob_joined).expect("rooms"), vec![ROOM.to_string()]);
4076        assert!(rooms_with_member_events_in_window(&conn, &rooms, bob_joined, i64::MAX).expect("rooms after the last member event").is_empty());
4077        assert!(rooms_with_member_events_in_window(&conn, &[], 0, i64::MAX).expect("empty room set").is_empty());
4078    }
4079
4080    // ---- a changed label is re-stamped into the user's member events ----
4081
4082    const ROOM_A: &str = "!roomA:example.org";
4083    const ROOM_B: &str = "!roomB:example.org";
4084    const ROOM_C: &str = "!roomC:example.org";
4085
4086    fn make_room_with_kind(conn: &Connection, room_id: &str, kind: RoomKind) {
4087        create_room(conn, room_id, kind, 1, T0, false, JoinRule::Invite, HistoryVisibility::Shared, None, None).expect("create room");
4088    }
4089
4090    fn member_content_in(conn: &Connection, room_id: &str, mxid: &str) -> serde_json::Value {
4091        let event = current_state_event(conn, room_id, "m.room.member", mxid).expect("query").expect("member event exists");
4092        serde_json::from_str(&event.content).expect("member content is json")
4093    }
4094
4095    fn member_event_count(conn: &Connection, room_id: &str, mxid: &str) -> i64 {
4096        conn.query_row(
4097            "SELECT COUNT(*) FROM events WHERE room_id = ?1 AND event_type = 'm.room.member' AND state_key = ?2",
4098            params![room_id, mxid],
4099            |row| row.get(0),
4100        )
4101        .expect("count member events")
4102    }
4103
4104    #[test]
4105    fn refresh_member_displayname_restamps_every_joined_room_and_skips_a_room_the_user_left() {
4106        let mut conn = test_conn();
4107        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000091", T0).expect("alice");
4108        let bob = ensure_matrix_user(&conn, 2, "bob0000000000000000000000000091", T0).expect("bob");
4109        for room in [ROOM_A, ROOM_B, ROOM_C] {
4110            make_room_with_kind(&conn, room, RoomKind::Group);
4111            apply_state_event(&mut conn, &StateEventWrite { event_id: &new_event_id(), room_id: room, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join","displayname":"old_alice"}"#, origin_server_ts: 1000, now: T0 })
4112                .expect("alice joins");
4113            apply_state_event(&mut conn, &StateEventWrite { event_id: &new_event_id(), room_id: room, sender_user_id: 2, event_type: "m.room.member", state_key: &bob, content: r#"{"membership":"join","displayname":"bob_nick"}"#, origin_server_ts: 1100, now: T0 })
4114                .expect("bob joins");
4115        }
4116        apply_state_event(&mut conn, &StateEventWrite { event_id: &new_event_id(), room_id: ROOM_C, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"leave"}"#, origin_server_ts: 1200, now: T0 }).expect("alice leaves C");
4117
4118        let refresh = refresh_member_displayname(&mut conn, 1, "new_alice", T0, 5000).expect("refresh");
4119
4120        assert_eq!(refresh.rooms_updated, 2, "the two rooms alice is still joined in");
4121        for room in [ROOM_A, ROOM_B] {
4122            let content = member_content_in(&conn, room, &alice);
4123            assert_eq!(content["membership"], "join");
4124            assert_eq!(content["displayname"], "new_alice");
4125            assert_eq!(member_event_count(&conn, room, &alice), 2, "a NEW member event lands; the old one stays in history");
4126            let event = current_state_event(&conn, room, "m.room.member", &alice).expect("query").expect("exists");
4127            assert_eq!(event.sender_user_id, 1, "a join refresh is sent by the user themself");
4128            assert_eq!(event.origin_server_ts, 5000);
4129            assert_eq!(member_content_in(&conn, room, &bob)["displayname"], "bob_nick", "another member's event is never touched");
4130        }
4131        assert_eq!(member_content_in(&conn, ROOM_C, &alice), serde_json::json!({ "membership": "leave" }), "the left room gets no new event");
4132        assert_eq!(member_event_count(&conn, ROOM_C, &alice), 2, "join + leave, nothing more");
4133        assert_eq!(refresh.affected_user_ids, HashSet::from([1, 2]), "alice and bob are woken; room C's members are not part of it");
4134
4135        let repeat = refresh_member_displayname(&mut conn, 1, "new_alice", T0, 6000).expect("second pass");
4136        assert_eq!(repeat, DisplaynameRefresh::default(), "an up-to-date event is skipped, so a repeat pass writes nothing");
4137        assert_eq!(member_event_count(&conn, ROOM_A, &alice), 2);
4138    }
4139
4140    #[test]
4141    fn refresh_member_displayname_keeps_an_invite_events_sender_and_is_direct() {
4142        let mut conn = test_conn();
4143        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000092", T0).expect("alice");
4144        let bob = ensure_matrix_user(&conn, 2, "bob0000000000000000000000000092", T0).expect("bob");
4145        make_room_with_kind(&conn, ROOM_A, RoomKind::Dm);
4146        apply_state_event(&mut conn, &StateEventWrite { event_id: &new_event_id(), room_id: ROOM_A, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join","displayname":"alice_nick"}"#, origin_server_ts: 1000, now: T0 })
4147            .expect("alice joins");
4148        apply_state_event(
4149            &mut conn,
4150            &StateEventWrite {
4151                event_id: &new_event_id(),
4152                room_id: ROOM_A,
4153                sender_user_id: 1,
4154                event_type: "m.room.member",
4155                state_key: &bob,
4156                content: r#"{"membership":"invite","is_direct":true}"#,
4157                origin_server_ts: 1100,
4158                now: T0,
4159            },
4160        )
4161        .expect("alice invites bob");
4162
4163        let refresh = refresh_member_displayname(&mut conn, 2, "bob_nick", T0, 5000).expect("refresh");
4164
4165        assert_eq!(refresh.rooms_updated, 1);
4166        let content = member_content_in(&conn, ROOM_A, &bob);
4167        assert_eq!(content, serde_json::json!({ "membership": "invite", "is_direct": true, "displayname": "bob_nick" }));
4168        let event = current_state_event(&conn, ROOM_A, "m.room.member", &bob).expect("query").expect("exists");
4169        assert_eq!(event.sender_user_id, 1, "the inviter stays the sender: stripped invite state reads the inviter off it");
4170        assert_eq!(room_member(&conn, ROOM_A, 2).expect("query").expect("row").membership, Membership::Invite, "still an invitation");
4171        assert_eq!(refresh.affected_user_ids, HashSet::from([1, 2]));
4172    }
4173
4174    #[test]
4175    fn refresh_member_displayname_is_a_noop_without_a_matrix_user_or_a_label() {
4176        let mut conn = test_conn();
4177        let alice = ensure_matrix_user(&conn, 1, "alice00000000000000000000000093", T0).expect("alice");
4178        make_room_with_kind(&conn, ROOM_A, RoomKind::Group);
4179        apply_state_event(&mut conn, &StateEventWrite { event_id: &new_event_id(), room_id: ROOM_A, sender_user_id: 1, event_type: "m.room.member", state_key: &alice, content: r#"{"membership":"join"}"#, origin_server_ts: 1000, now: T0 }).expect("alice joins");
4180
4181        assert_eq!(refresh_member_displayname(&mut conn, 99, "ghost", T0, 5000).expect("unknown user"), DisplaynameRefresh::default());
4182        assert_eq!(refresh_member_displayname(&mut conn, 1, "", T0, 5000).expect("empty label"), DisplaynameRefresh::default());
4183        assert_eq!(member_event_count(&conn, ROOM_A, &alice), 1);
4184        assert_eq!(matrix_user_ids(&conn).expect("ids"), vec![1]);
4185    }
4186
4187    // ---- P16 S-d: adopting a native DM room for a legacy conversation ----
4188
4189    fn native_dm_room(conn: &Connection) {
4190        create_room(conn, ROOM, RoomKind::Dm, 1, T0, true, JoinRule::Invite, HistoryVisibility::Shared, Some("1:2"), None).expect("create dm room");
4191    }
4192
4193    fn legacy_import(legacy_message_id: i64, event_id: &str) -> LegacyDmMessageImport {
4194        LegacyDmMessageImport {
4195            legacy_message_id,
4196            event_id: event_id.to_string(),
4197            sender_user_id: 1,
4198            content: "{}".to_string(),
4199            origin_server_ts: 1000 + legacy_message_id,
4200        }
4201    }
4202
4203    fn adoption_of(legacy_dm_id: i64) -> DmAdoption<'static> {
4204        DmAdoption { room_id: ROOM, legacy_dm_id, key_events: &[], key_events_origin_server_ts: 500, now: T0 }
4205    }
4206
4207    #[test]
4208    fn adopt_dm_room_for_legacy_binds_the_room_and_imports_the_messages() {
4209        let mut conn = test_conn();
4210        ensure_legacy_dm_map_table(&conn);
4211        native_dm_room(&conn);
4212
4213        let counts = adopt_dm_room_for_legacy(&mut conn, adoption_of(7), &[legacy_import(1, "$l1"), legacy_import(2, "$l2")], &[])
4214            .expect("adopt")
4215            .expect("the room is adoptable");
4216        assert_eq!(counts.messages_imported, 2);
4217        assert_eq!(room_by_legacy_dm_id(&conn, 7).expect("query").expect("bound").id, ROOM);
4218        assert_eq!(highest_mapped_legacy_message_id(&conn, ROOM).expect("query"), Some(2));
4219        assert_eq!(get_event(&conn, "$l1").expect("query").expect("imported").room_id, ROOM);
4220    }
4221
4222    #[test]
4223    fn adopt_dm_room_for_legacy_writes_a_key_event_only_where_the_room_has_none() {
4224        let mut conn = test_conn();
4225        ensure_legacy_dm_map_table(&conn);
4226        native_dm_room(&conn);
4227        let existing = apply_state_event(&mut conn, &StateEventWrite { event_id: "$k-existing", room_id: ROOM, sender_user_id: 1, event_type: "org.example.legacy_dm_key", state_key: "@a:example.org", content: r#"{"public_key_b64":"AAAA"}"#, origin_server_ts: 900, now: T0 })
4228            .expect("existing key event");
4229        let key_events = [
4230            NewStateEvent {
4231                event_id: "$k-a".to_string(),
4232                sender_user_id: 1,
4233                event_type: "org.example.legacy_dm_key".to_string(),
4234                state_key: "@a:example.org".to_string(),
4235                content: r#"{"public_key_b64":"BBBB"}"#.to_string(),
4236            },
4237            NewStateEvent {
4238                event_id: "$k-b".to_string(),
4239                sender_user_id: 2,
4240                event_type: "org.example.legacy_dm_key".to_string(),
4241                state_key: "@b:example.org".to_string(),
4242                content: r#"{"public_key_b64":"CCCC"}"#.to_string(),
4243            },
4244        ];
4245        let adoption = DmAdoption { key_events: &key_events, ..adoption_of(7) };
4246        adopt_dm_room_for_legacy(&mut conn, adoption, &[], &[]).expect("adopt").expect("adoptable");
4247
4248        let a = current_state_event(&conn, ROOM, "org.example.legacy_dm_key", "@a:example.org").expect("query").expect("still present");
4249        assert_eq!(a.event_id, existing.event_id, "an existing key event is never overwritten");
4250        let b = current_state_event(&conn, ROOM, "org.example.legacy_dm_key", "@b:example.org").expect("query").expect("added");
4251        assert_eq!(b.event_id, "$k-b");
4252    }
4253
4254    #[test]
4255    fn adopt_dm_room_for_legacy_refuses_a_room_that_is_bound_or_not_a_dm() {
4256        let mut conn = test_conn();
4257        ensure_legacy_dm_map_table(&conn);
4258        make_room(&conn);
4259        assert!(adopt_dm_room_for_legacy(&mut conn, adoption_of(7), &[legacy_import(1, "$l1")], &[]).expect("adopt").is_none(), "a group room is never adopted");
4260        assert!(room_by_legacy_dm_id(&conn, 7).expect("query").is_none());
4261
4262        let mut bound = test_conn();
4263        create_room(&bound, ROOM, RoomKind::Dm, 1, T0, true, JoinRule::Invite, HistoryVisibility::Shared, Some("1:2"), Some(9)).expect("create bound room");
4264        assert!(adopt_dm_room_for_legacy(&mut bound, adoption_of(7), &[legacy_import(1, "$l1")], &[]).expect("adopt").is_none(), "an already-bound room is never re-bound");
4265        assert_eq!(room_by_legacy_dm_id(&bound, 9).expect("query").expect("still bound to 9").id, ROOM);
4266        assert!(get_event(&bound, "$l1").expect("query").is_none(), "a refused adoption writes nothing");
4267    }
4268
4269    #[test]
4270    fn adopt_dm_room_for_legacy_rolls_back_the_binding_when_an_import_fails() {
4271        let mut conn = test_conn();
4272        ensure_legacy_dm_map_table(&conn);
4273        native_dm_room(&conn);
4274
4275        // The second import reuses the first one's event id — refused by the
4276        // UNIQUE index, after the room was already bound inside the transaction.
4277        let result = adopt_dm_room_for_legacy(&mut conn, adoption_of(7), &[legacy_import(1, "$dup"), legacy_import(2, "$dup")], &[]);
4278        assert!(result.is_err());
4279
4280        assert!(room_by_legacy_dm_id(&conn, 7).expect("query").is_none(), "the binding must roll back with the failed import");
4281        assert!(get_event(&conn, "$dup").expect("query").is_none(), "no imported event survives");
4282        assert_eq!(highest_mapped_legacy_message_id(&conn, ROOM).expect("query"), None);
4283    }
4284}