Skip to main content

arminius_lib_db_todevice/
lib.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Queries related to the `to_device_messages` table.
6
7use arminius_lib_db::{Database, DatabaseError, ResultExt as _};
8use arminius_lib_model::types::StreamId;
9use itertools::multiunzip;
10use ruma::{
11	DeviceId, OwnedUserId, UserId, events::ToDeviceEventType, to_device::DeviceIdOrAllDevices,
12};
13use sqlx::postgres::PgQueryResult;
14
15/// A message that an user wants to send to one or more devices
16#[derive(Debug)]
17pub struct ToDeviceMessage {
18	/// The user to send the message to
19	pub user: OwnedUserId,
20	/// Destination of the to-device message.
21	pub device: DeviceIdOrAllDevices,
22	/// The content of the event
23	pub message: serde_json::Value,
24}
25
26/// A to_device event received as part of a sync response
27#[derive(Debug)]
28pub struct SyncToDeviceEvent {
29	/// Sender of the event
30	pub sender: OwnedUserId,
31	/// Type of the to_device event
32	pub event_type: ToDeviceEventType,
33	/// Content of the event
34	pub content: serde_json::Value,
35	/// The stream_id of the event (See <https://bundesmessenger.gitlab.io/backend/neo/arminius/arminius/architecture/sync.html>)
36	pub stream_id: StreamId,
37}
38
39/// Queries related to the room table.
40// Queries are usually used as extension traits so suggest `import as _`.
41#[rust_analyzer::prefer_underscore_import]
42pub trait ToDeviceQueries: Database {
43	/// Inserts a list of send-to-device messages into the database
44	#[tracing::instrument(skip(self))]
45	async fn insert_to_device_messages(
46		&mut self,
47		event_type: ToDeviceEventType,
48		sender: &UserId,
49		messages: Vec<ToDeviceMessage>,
50	) -> Result<PgQueryResult, DatabaseError> {
51		// sqlx does not support `Vec<Option<_>>` neatly, so we pass along a
52		// `Vec<bool>` next to our actual values
53		let (users, all_devices, devices, messages): (
54			Vec<String>,
55			Vec<bool>,
56			Vec<String>,
57			Vec<serde_json::Value>,
58		) = multiunzip(messages.into_iter().map(|msg| {
59			(
60				msg.user.to_string(),
61				matches!(msg.device, DeviceIdOrAllDevices::AllDevices),
62				msg.device.to_string(),
63				msg.message,
64			)
65		}));
66		Ok(sqlx::query!(
67			"
68			WITH raw_rows AS (
69			    SELECT
70			        *
71			    FROM
72			        UNNEST($1::text[], $2::bool[], $3::text[], $4::json[]) AS t (user_id,
73			            all_devices,
74			            device_id,
75			            message)
76			),
77			inserting_rows AS (
78			    SELECT
79			        d.nid,
80			        message,
81			        $5 AS event_type,
82			        $6 AS sender
83			    FROM
84			        raw_rows r
85			        LEFT JOIN users u ON u.user_id = r.user_id
86			        LEFT JOIN devices d ON d.user_nid = u.nid
87			            AND r.all_devices
88			            OR (NOT r.all_devices
89			                AND d.device_id = r.device_id))
90			    INSERT INTO to_device_messages (device_nid, event_content, event_type, sender) (
91			        SELECT
92			            *
93			        FROM
94			            inserting_rows);
95		",
96			&users,
97			&all_devices,
98			&devices,
99			&messages,
100			&event_type.to_string(),
101			sender.as_str(),
102		)
103		.execute(self.connection().await?)
104		.await?)
105	}
106
107	/// Insert a to-device transaction, used in federation
108	#[tracing::instrument(skip(self))]
109	async fn insert_to_device_transaction(
110		&mut self,
111		origin: &ruma::ServerName,
112		txnid: &ruma::TransactionId,
113	) -> Result<(), DatabaseError> {
114		sqlx::query!(
115			r#"
116			INSERT INTO federated_to_device_transactions (origin, txnid)
117				VALUES ($1, $2)
118			"#,
119			origin.as_str(),
120			txnid.as_str()
121		)
122		.execute(self.connection().await?)
123		.await?;
124		Ok(())
125	}
126
127	/// Returns true if a homeserver has already used the transaction ID for to-device messages
128	#[tracing::instrument(skip(self))]
129	async fn to_device_transaction_exists(
130		&mut self,
131		origin: &ruma::ServerName,
132		txnid: &ruma::TransactionId,
133	) -> Result<bool, DatabaseError> {
134		let result = sqlx::query!(
135			r#"
136			SELECT
137				*
138			FROM
139				federated_to_device_transactions
140			WHERE
141				origin = $1
142				AND txnid = $2
143			"#,
144			origin.as_str(),
145			txnid.as_str()
146		)
147		.fetch_optional(self.connection().await?)
148		.await?;
149		Ok(result.is_some())
150	}
151
152	/// Gets the todevice messages sent to a specific device since last sync.
153	#[tracing::instrument(skip(self))]
154	async fn fetch_sync_todevice_messages(
155		&mut self,
156		user_id: &UserId,
157		device_id: &DeviceId,
158		from: StreamId,
159	) -> Result<Vec<SyncToDeviceEvent>, DatabaseError> {
160		let rows = sqlx::query!(
161			"
162			SELECT
163			    sender,
164			    m.event_type,
165			    m.event_content,
166			    m.stream_id
167			FROM
168			    to_device_messages m
169			    JOIN devices d ON d.nid = m.device_nid
170			    JOIN users u ON u.nid = d.user_nid
171			WHERE
172			    user_id = $1
173			    AND d.device_id = $2
174			    AND m.stream_id > $3
175			    AND m.stream_id <= max_safe_seq ('device_message_streamids')
176			",
177			user_id.as_str(),
178			device_id.as_str(),
179			from,
180		)
181		.fetch_all(self.connection().await?)
182		.await?;
183
184		rows.into_iter()
185			.map(|row| {
186				Ok(SyncToDeviceEvent {
187					sender: OwnedUserId::try_from(row.sender)
188						.invalid_data("Invalid user ID in database")?,
189					event_type: ToDeviceEventType::from(row.event_type),
190					content: row.event_content,
191					stream_id: row.stream_id,
192				})
193			})
194			.collect()
195	}
196
197	/// Imports a single to-device message from a homeserver export. Returns
198	/// `true` if a row was inserted.
199	///
200	/// arminius's `stream_id` is a per-row UNIQUE sync cursor, but synapse
201	/// shares one `device_inbox` stream_id across an entire fan-out (many
202	/// recipient devices), so it cannot be preserved. Each recipient row gets a
203	/// fresh per-row `stream_id` from the `device_message_streamids` sequence
204	/// via the column default, exactly like a live send, so no sequence
205	/// realignment is needed after the import.
206	///
207	/// If the recipient of the message does not exist, `Ok(false)` is returned.
208	#[tracing::instrument(skip(self, content))]
209	async fn import_to_device_message(
210		&mut self,
211		user_id: &UserId,
212		device_id: &DeviceId,
213		sender: &UserId,
214		event_type: &ToDeviceEventType,
215		content: serde_json::Value,
216	) -> Result<bool, DatabaseError> {
217		let result = sqlx::query!(
218			r#"
219			INSERT INTO to_device_messages (device_nid, event_content, event_type, sender)
220			SELECT
221				d.nid,
222				$4::json,
223				$5,
224				$3
225			FROM
226				devices d
227				JOIN users u ON d.user_nid = u.nid
228			WHERE
229				u.user_id = $1
230				AND d.device_id = $2
231				AND NOT EXISTS (
232					SELECT
233						1
234					FROM
235						to_device_messages existing
236					WHERE
237						existing.device_nid = d.nid
238						AND existing.sender = $3
239						AND existing.event_type = $5
240						AND existing.event_content::text = $4::json::text)
241			"#,
242			user_id.as_str(),       // $1
243			device_id.as_str(),     // $2
244			sender.as_str(),        // $3
245			content,                // $4
246			event_type.to_string(), // $5
247		)
248		.execute(self.connection().await?)
249		.await?;
250		Ok(result.rows_affected() > 0)
251	}
252
253	/// When we are syncing past old send-to-device messages, these old messages
254	/// should be deleted from the server, per the spec.
255	///
256	/// See <https://spec.matrix.org/v1.15/client-server-api/#server-behaviour-6>.
257	#[tracing::instrument(skip(self))]
258	async fn delete_synced_todevice_messages(
259		&mut self,
260		user_id: &UserId,
261		device_id: &DeviceId,
262		delete_to_inclusive: StreamId,
263	) -> Result<PgQueryResult, DatabaseError> {
264		Ok(sqlx::query!(
265			r#"
266			DELETE FROM to_device_messages m USING devices d, users u
267			WHERE u.nid = d.user_nid
268				AND d.nid = m.device_nid
269				AND u.user_id = $1
270				AND d.device_id = $2
271				AND m.stream_id <= $3
272				AND m.stream_id <= max_safe_seq ('device_message_streamids')
273			"#,
274			user_id.as_str(),
275			device_id.as_str(),
276			delete_to_inclusive,
277		)
278		.execute(self.connection().await?)
279		.await?)
280	}
281}
282
283impl<T> ToDeviceQueries for T where T: Database {}