1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use atuin_domain::record::{
5 EncryptedData, HostId, Record, RecordIdx, RecordSeriesKey, RecordStatus, RecordTag,
6};
7use atuin_server_database::models::{NewSession, NewUser, Session, User};
8use atuin_server_database::{Database, DbError, DbResult, DbSettings};
9use rand::Rng;
10use sqlx::postgres::PgPoolOptions;
11use tracing::instrument;
12use uuid::Uuid;
13use wrappers::DbRecord;
14
15mod wrappers;
16
17const MIN_PG_VERSION: u32 = 14;
18
19#[derive(Clone)]
20pub struct Postgres {
21 pool: sqlx::Pool<sqlx::postgres::Postgres>,
22 read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,
24}
25
26impl Postgres {
27 fn read_pool(&self) -> &sqlx::Pool<sqlx::postgres::Postgres> {
30 self.read_pool.as_ref().unwrap_or(&self.pool)
31 }
32}
33
34#[async_trait]
35impl Database for Postgres {
36 async fn new(settings: &DbSettings) -> DbResult<Self> {
37 let pool =
38 PgPoolOptions::new().max_connections(100).connect(settings.db_uri.as_str()).await?;
39
40 let pg_major_version: u32 = pool
43 .acquire()
44 .await?
45 .server_version_num()
46 .ok_or(DbError::Other(eyre::Report::msg("could not get PostgreSQL version")))?
47 / 10000;
48
49 if pg_major_version < MIN_PG_VERSION {
50 return Err(DbError::Other(eyre::Report::msg(format!(
51 "unsupported PostgreSQL version {pg_major_version}, minimum required is \
52 {MIN_PG_VERSION}"
53 ))));
54 }
55
56 sqlx::migrate!("./migrations")
57 .run(&pool)
58 .await
59 .map_err(|error| DbError::Other(error.into()))?;
60
61 let read_pool = if let Some(read_db_uri) = &settings.read_db_uri {
63 tracing::info!("Connecting to read replica database");
64 let read_pool =
65 PgPoolOptions::new().max_connections(100).connect(read_db_uri.as_str()).await?;
66
67 let read_pg_major_version: u32 =
69 read_pool.acquire().await?.server_version_num().ok_or(DbError::Other(
70 eyre::Report::msg("could not get PostgreSQL version from read replica"),
71 ))? / 10000;
72
73 if read_pg_major_version < MIN_PG_VERSION {
74 return Err(DbError::Other(eyre::Report::msg(format!(
75 "unsupported PostgreSQL version {read_pg_major_version} on read replica, \
76 minimum required is {MIN_PG_VERSION}"
77 ))));
78 }
79
80 Some(read_pool)
81 } else {
82 None
83 };
84
85 Ok(Self { pool, read_pool })
86 }
87
88 #[instrument(skip_all)]
89 async fn get_session(&self, token: &str) -> DbResult<Session> {
90 sqlx::query_as("select id, user_id, token from sessions where token = $1")
91 .bind(token)
92 .fetch_one(self.read_pool())
93 .await
94 .map_err(Into::into)
95 }
96
97 #[instrument(skip_all)]
98 async fn get_user(&self, username: &str) -> DbResult<User> {
99 sqlx::query_as("select id, username, email, password from users where username = $1")
100 .bind(username)
101 .fetch_one(self.read_pool())
102 .await
103 .map_err(Into::into)
104 }
105
106 #[instrument(skip_all)]
107 async fn get_session_user(&self, token: &str) -> DbResult<User> {
108 sqlx::query_as(
109 "select users.id, users.username, users.email, users.password from users
110 inner join sessions
111 on users.id = sessions.user_id
112 and sessions.token = $1",
113 )
114 .bind(token)
115 .fetch_one(self.read_pool())
116 .await
117 .map_err(Into::into)
118 }
119
120 async fn delete_store(&self, user: &User) -> DbResult<()> {
121 let mut tx = self.pool.begin().await?;
122
123 sqlx::query(
124 "delete from store
125 where user_id = $1",
126 )
127 .bind(user.id)
128 .execute(&mut *tx)
129 .await?;
130
131 sqlx::query(
132 "delete from store_idx_cache
133 where user_id = $1",
134 )
135 .bind(user.id)
136 .execute(&mut *tx)
137 .await?;
138
139 tx.commit().await?;
140
141 Ok(())
142 }
143
144 #[instrument(skip_all)]
145 async fn delete_user(&self, u: &User) -> DbResult<()> {
146 sqlx::query("delete from sessions where user_id = $1")
147 .bind(u.id)
148 .execute(&self.pool)
149 .await?;
150
151 sqlx::query("delete from history where user_id = $1")
152 .bind(u.id)
153 .execute(&self.pool)
154 .await?;
155
156 sqlx::query("delete from store where user_id = $1").bind(u.id).execute(&self.pool).await?;
157
158 sqlx::query("delete from total_history_count_user where user_id = $1")
159 .bind(u.id)
160 .execute(&self.pool)
161 .await?;
162
163 sqlx::query("delete from users where id = $1").bind(u.id).execute(&self.pool).await?;
164
165 Ok(())
166 }
167
168 #[instrument(skip_all)]
169 async fn update_user_password(&self, user: &User) -> DbResult<()> {
170 sqlx::query(
171 "update users
172 set password = $1
173 where id = $2",
174 )
175 .bind(&user.password)
176 .bind(user.id)
177 .execute(&self.pool)
178 .await?;
179
180 Ok(())
181 }
182
183 #[instrument(skip_all)]
184 async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
185 let email: &str = &user.email;
186 let username: &str = &user.username;
187 let password: &str = &user.password;
188
189 let res: (i64,) = sqlx::query_as(
190 "insert into users
191 (username, email, password)
192 values($1, $2, $3)
193 returning id",
194 )
195 .bind(username)
196 .bind(email)
197 .bind(password)
198 .fetch_one(&self.pool)
199 .await?;
200
201 Ok(res.0)
202 }
203
204 #[instrument(skip_all)]
205 async fn add_session(&self, session: &NewSession) -> DbResult<()> {
206 let token: &str = &session.token;
207
208 sqlx::query(
209 "insert into sessions
210 (user_id, token)
211 values($1, $2)",
212 )
213 .bind(session.user_id)
214 .bind(token)
215 .execute(&self.pool)
216 .await?;
217
218 Ok(())
219 }
220
221 #[instrument(skip_all)]
222 async fn get_user_session(&self, u: &User) -> DbResult<Session> {
223 sqlx::query_as("select id, user_id, token from sessions where user_id = $1")
224 .bind(u.id)
225 .fetch_one(self.read_pool())
226 .await
227 .map_err(Into::into)
228 }
229
230 #[instrument(skip_all)]
231 async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {
232 let mut tx = self.pool.begin().await?;
233
234 let mut heads = HashMap::<(HostId, &str), u64>::new();
242
243 for i in records {
244 let id = atuin_common::utils::uuid_v7();
245
246 let result = sqlx::query(
247 "insert into store
248 (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id)
249 values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
250 on conflict do nothing
251 ",
252 )
253 .bind(id)
254 .bind(i.id)
255 .bind(i.host.id)
256 .bind(i.idx as i64)
257 .bind(i.timestamp as i64) .bind(i.version.as_str())
259 .bind(i.tag.as_str())
260 .bind(&i.data.raw)
261 .bind(&i.data.cek)
262 .bind(user.id)
263 .execute(&mut *tx)
264 .await?;
265
266 if result.rows_affected() > 0 {
268 heads
269 .entry((i.host.id, i.tag.as_str()))
270 .and_modify(|e| {
271 if i.idx > *e {
272 *e = i.idx
273 }
274 })
275 .or_insert(i.idx);
276 }
277 }
278
279 for ((host, tag), idx) in heads {
281 sqlx::query(
282 "insert into store_idx_cache
283 (user_id, host, tag, idx)
284 values ($1, $2, $3, $4)
285 on conflict(user_id, host, tag) do update set idx = greatest(store_idx_cache.idx, \
286 $4)
287 ",
288 )
289 .bind(user.id)
290 .bind(host)
291 .bind(tag)
292 .bind(idx as i64)
293 .execute(&mut *tx)
294 .await?;
295 }
296
297 tx.commit().await?;
298
299 Ok(())
300 }
301
302 #[instrument(skip_all)]
303 async fn next_records(
304 &self,
305 user: &User,
306 series: &RecordSeriesKey,
307 start: Option<RecordIdx>,
308 count: u64,
309 ) -> DbResult<Vec<Record<EncryptedData>>> {
310 tracing::debug!("{:?} - {:?} - {:?}", series.host_id, series.tag, start);
311 let start = start.unwrap_or(0);
312
313 let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(
314 "select client_id, host, idx, timestamp, version, tag, data, cek from store
315 where user_id = $1
316 and tag = $2
317 and host = $3
318 and idx >= $4
319 order by idx asc
320 limit $5",
321 )
322 .bind(user.id)
323 .bind(series.tag.as_str())
324 .bind(series.host_id)
325 .bind(start as i64)
326 .bind(count as i64)
327 .fetch_all(self.read_pool())
328 .await
329 .map_err(Into::into);
330
331 let ret = match records {
332 Ok(records) => {
333 let records: Vec<Record<EncryptedData>> = records
334 .into_iter()
335 .map(|f| {
336 let record: Record<EncryptedData> = f.into();
337 record
338 })
339 .collect();
340
341 records
342 }
343 Err(DbError::NotFound) => {
344 tracing::debug!("no records found in store: {:?}/{}", series.host_id, series.tag);
345 return Ok(vec![]);
346 }
347 Err(e) => return Err(e),
348 };
349
350 Ok(ret)
351 }
352
353 async fn status(&self, user: &User) -> DbResult<RecordStatus> {
354 const STATUS_SQL: &str =
355 "select host, tag, max(idx) from store where user_id = $1 group by host, tag";
356
357 let idx_cache_rollout = std::env::var("IDX_CACHE_ROLLOUT").unwrap_or("0".to_string());
364 let idx_cache_rollout = idx_cache_rollout.parse::<f64>().unwrap_or(0.0);
365 let use_idx_cache = rand::thread_rng().gen_bool(idx_cache_rollout / 100.0);
366
367 let mut res: Vec<(Uuid, String, i64)> = if use_idx_cache {
368 tracing::debug!("using idx cache for user {}", user.id);
369 sqlx::query_as("select host, tag, idx from store_idx_cache where user_id = $1")
370 .bind(user.id)
371 .fetch_all(self.read_pool())
372 .await?
373 } else {
374 tracing::debug!("using aggregate query for user {}", user.id);
375 sqlx::query_as(STATUS_SQL).bind(user.id).fetch_all(self.read_pool()).await?
376 };
377
378 res.sort();
379
380 let mut status = RecordStatus::new();
381
382 for i in &res {
383 status.set_raw(
384 RecordSeriesKey::new(HostId(i.0), RecordTag::from(i.1.clone())),
385 i.2 as u64,
386 );
387 }
388
389 Ok(status)
390 }
391}