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