1use std::collections::HashMap;
2use std::ops::Range;
3
4use rand::Rng;
5
6use async_trait::async_trait;
7use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
8use atuin_server_database::models::{History, NewHistory, NewSession, NewUser, Session, User};
9use atuin_server_database::{Database, DbError, DbResult, DbSettings, into_utc};
10use futures_util::TryStreamExt;
11use sqlx::Row;
12use sqlx::postgres::PgPoolOptions;
13
14use time::OffsetDateTime;
15use tracing::instrument;
16use uuid::Uuid;
17use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
18
19mod wrappers;
20
21const MIN_PG_VERSION: u32 = 14;
22
23#[derive(Clone)]
24pub struct Postgres {
25 pool: sqlx::Pool<sqlx::postgres::Postgres>,
26 read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,
28}
29
30impl Postgres {
31 fn read_pool(&self) -> &sqlx::Pool<sqlx::postgres::Postgres> {
34 self.read_pool.as_ref().unwrap_or(&self.pool)
35 }
36}
37
38#[async_trait]
39impl Database for Postgres {
40 async fn new(settings: &DbSettings) -> DbResult<Self> {
41 let pool = PgPoolOptions::new()
42 .max_connections(100)
43 .connect(settings.db_uri.as_str())
44 .await?;
45
46 let pg_major_version: u32 =
49 pool.acquire()
50 .await?
51 .server_version_num()
52 .ok_or(DbError::Other(eyre::Report::msg(
53 "could not get PostgreSQL version",
54 )))?
55 / 10000;
56
57 if pg_major_version < MIN_PG_VERSION {
58 return Err(DbError::Other(eyre::Report::msg(format!(
59 "unsupported PostgreSQL version {pg_major_version}, minimum required is {MIN_PG_VERSION}"
60 ))));
61 }
62
63 sqlx::migrate!("./migrations")
64 .run(&pool)
65 .await
66 .map_err(|error| DbError::Other(error.into()))?;
67
68 let read_pool = if let Some(read_db_uri) = &settings.read_db_uri {
70 tracing::info!("Connecting to read replica database");
71 let read_pool = PgPoolOptions::new()
72 .max_connections(100)
73 .connect(read_db_uri.as_str())
74 .await?;
75
76 let read_pg_major_version: u32 = read_pool
78 .acquire()
79 .await?
80 .server_version_num()
81 .ok_or(DbError::Other(eyre::Report::msg(
82 "could not get PostgreSQL version from read replica",
83 )))?
84 / 10000;
85
86 if read_pg_major_version < MIN_PG_VERSION {
87 return Err(DbError::Other(eyre::Report::msg(format!(
88 "unsupported PostgreSQL version {read_pg_major_version} on read replica, minimum required is {MIN_PG_VERSION}"
89 ))));
90 }
91
92 Some(read_pool)
93 } else {
94 None
95 };
96
97 Ok(Self { pool, read_pool })
98 }
99
100 #[instrument(skip_all)]
101 async fn get_session(&self, token: &str) -> DbResult<Session> {
102 sqlx::query_as("select id, user_id, token from sessions where token = $1")
103 .bind(token)
104 .fetch_one(self.read_pool())
105 .await
106 .map_err(Into::into)
107 .map(|DbSession(session)| session)
108 }
109
110 #[instrument(skip_all)]
111 async fn get_user(&self, username: &str) -> DbResult<User> {
112 sqlx::query_as("select id, username, email, password from users where username = $1")
113 .bind(username)
114 .fetch_one(self.read_pool())
115 .await
116 .map_err(Into::into)
117 .map(|DbUser(user)| user)
118 }
119
120 #[instrument(skip_all)]
121 async fn get_session_user(&self, token: &str) -> DbResult<User> {
122 sqlx::query_as(
123 "select users.id, users.username, users.email, users.password from users
124 inner join sessions
125 on users.id = sessions.user_id
126 and sessions.token = $1",
127 )
128 .bind(token)
129 .fetch_one(self.read_pool())
130 .await
131 .map_err(Into::into)
132 .map(|DbUser(user)| user)
133 }
134
135 #[instrument(skip_all)]
136 async fn count_history(&self, user: &User) -> DbResult<i64> {
137 let res: (i64,) = sqlx::query_as(
142 "select count(1) from history
143 where user_id = $1",
144 )
145 .bind(user.id)
146 .fetch_one(self.read_pool())
147 .await?;
148
149 Ok(res.0)
150 }
151
152 #[instrument(skip_all)]
153 async fn count_history_cached(&self, user: &User) -> DbResult<i64> {
154 let res: (i32,) = sqlx::query_as(
155 "select total from total_history_count_user
156 where user_id = $1",
157 )
158 .bind(user.id)
159 .fetch_one(self.read_pool())
160 .await?;
161
162 Ok(res.0 as i64)
163 }
164
165 async fn delete_store(&self, user: &User) -> DbResult<()> {
166 let mut tx = self.pool.begin().await?;
167
168 sqlx::query(
169 "delete from store
170 where user_id = $1",
171 )
172 .bind(user.id)
173 .execute(&mut *tx)
174 .await?;
175
176 sqlx::query(
177 "delete from store_idx_cache
178 where user_id = $1",
179 )
180 .bind(user.id)
181 .execute(&mut *tx)
182 .await?;
183
184 tx.commit().await?;
185
186 Ok(())
187 }
188
189 async fn delete_history(&self, user: &User, id: String) -> DbResult<()> {
190 sqlx::query(
191 "update history
192 set deleted_at = $3
193 where user_id = $1
194 and client_id = $2
195 and deleted_at is null", )
197 .bind(user.id)
198 .bind(id)
199 .bind(OffsetDateTime::now_utc())
200 .fetch_all(&self.pool)
201 .await?;
202
203 Ok(())
204 }
205
206 #[instrument(skip_all)]
207 async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>> {
208 let res = sqlx::query(
213 "select client_id from history
214 where user_id = $1
215 and deleted_at is not null",
216 )
217 .bind(user.id)
218 .fetch_all(self.read_pool())
219 .await?;
220
221 let res = res
222 .iter()
223 .map(|row| row.get::<String, _>("client_id"))
224 .collect();
225
226 Ok(res)
227 }
228
229 #[instrument(skip_all)]
230 async fn count_history_range(
231 &self,
232 user: &User,
233 range: Range<OffsetDateTime>,
234 ) -> DbResult<i64> {
235 let res: (i64,) = sqlx::query_as(
236 "select count(1) from history
237 where user_id = $1
238 and timestamp >= $2::date
239 and timestamp < $3::date",
240 )
241 .bind(user.id)
242 .bind(into_utc(range.start))
243 .bind(into_utc(range.end))
244 .fetch_one(self.read_pool())
245 .await?;
246
247 Ok(res.0)
248 }
249
250 #[instrument(skip_all)]
251 async fn list_history(
252 &self,
253 user: &User,
254 created_after: OffsetDateTime,
255 since: OffsetDateTime,
256 host: &str,
257 page_size: i64,
258 ) -> DbResult<Vec<History>> {
259 let res = sqlx::query_as(
260 "select id, client_id, user_id, hostname, timestamp, data, created_at from history
261 where user_id = $1
262 and hostname != $2
263 and created_at >= $3
264 and timestamp >= $4
265 order by timestamp asc
266 limit $5",
267 )
268 .bind(user.id)
269 .bind(host)
270 .bind(into_utc(created_after))
271 .bind(into_utc(since))
272 .bind(page_size)
273 .fetch(self.read_pool())
274 .map_ok(|DbHistory(h)| h)
275 .try_collect()
276 .await?;
277
278 Ok(res)
279 }
280
281 #[instrument(skip_all)]
282 async fn add_history(&self, history: &[NewHistory]) -> DbResult<()> {
283 let mut tx = self.pool.begin().await?;
284
285 for i in history {
286 let client_id: &str = &i.client_id;
287 let hostname: &str = &i.hostname;
288 let data: &str = &i.data;
289
290 sqlx::query(
291 "insert into history
292 (client_id, user_id, hostname, timestamp, data)
293 values ($1, $2, $3, $4, $5)
294 on conflict do nothing
295 ",
296 )
297 .bind(client_id)
298 .bind(i.user_id)
299 .bind(hostname)
300 .bind(i.timestamp)
301 .bind(data)
302 .execute(&mut *tx)
303 .await?;
304 }
305
306 tx.commit().await?;
307
308 Ok(())
309 }
310
311 #[instrument(skip_all)]
312 async fn delete_user(&self, u: &User) -> DbResult<()> {
313 sqlx::query("delete from sessions where user_id = $1")
314 .bind(u.id)
315 .execute(&self.pool)
316 .await?;
317
318 sqlx::query("delete from history where user_id = $1")
319 .bind(u.id)
320 .execute(&self.pool)
321 .await?;
322
323 sqlx::query("delete from store where user_id = $1")
324 .bind(u.id)
325 .execute(&self.pool)
326 .await?;
327
328 sqlx::query("delete from total_history_count_user where user_id = $1")
329 .bind(u.id)
330 .execute(&self.pool)
331 .await?;
332
333 sqlx::query("delete from users where id = $1")
334 .bind(u.id)
335 .execute(&self.pool)
336 .await?;
337
338 Ok(())
339 }
340
341 #[instrument(skip_all)]
342 async fn update_user_password(&self, user: &User) -> DbResult<()> {
343 sqlx::query(
344 "update users
345 set password = $1
346 where id = $2",
347 )
348 .bind(&user.password)
349 .bind(user.id)
350 .execute(&self.pool)
351 .await?;
352
353 Ok(())
354 }
355
356 #[instrument(skip_all)]
357 async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
358 let email: &str = &user.email;
359 let username: &str = &user.username;
360 let password: &str = &user.password;
361
362 let res: (i64,) = sqlx::query_as(
363 "insert into users
364 (username, email, password)
365 values($1, $2, $3)
366 returning id",
367 )
368 .bind(username)
369 .bind(email)
370 .bind(password)
371 .fetch_one(&self.pool)
372 .await?;
373
374 Ok(res.0)
375 }
376
377 #[instrument(skip_all)]
378 async fn add_session(&self, session: &NewSession) -> DbResult<()> {
379 let token: &str = &session.token;
380
381 sqlx::query(
382 "insert into sessions
383 (user_id, token)
384 values($1, $2)",
385 )
386 .bind(session.user_id)
387 .bind(token)
388 .execute(&self.pool)
389 .await?;
390
391 Ok(())
392 }
393
394 #[instrument(skip_all)]
395 async fn get_user_session(&self, u: &User) -> DbResult<Session> {
396 sqlx::query_as("select id, user_id, token from sessions where user_id = $1")
397 .bind(u.id)
398 .fetch_one(self.read_pool())
399 .await
400 .map_err(Into::into)
401 .map(|DbSession(session)| session)
402 }
403
404 #[instrument(skip_all)]
405 async fn oldest_history(&self, user: &User) -> DbResult<History> {
406 sqlx::query_as(
407 "select id, client_id, user_id, hostname, timestamp, data, created_at from history
408 where user_id = $1
409 order by timestamp asc
410 limit 1",
411 )
412 .bind(user.id)
413 .fetch_one(self.read_pool())
414 .await
415 .map_err(Into::into)
416 .map(|DbHistory(h)| h)
417 }
418
419 #[instrument(skip_all)]
420 async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {
421 let mut tx = self.pool.begin().await?;
422
423 let mut heads = HashMap::<(HostId, &str), u64>::new();
431
432 for i in records {
433 let id = atuin_common::utils::uuid_v7();
434
435 let result = sqlx::query(
436 "insert into store
437 (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id)
438 values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
439 on conflict do nothing
440 ",
441 )
442 .bind(id)
443 .bind(i.id)
444 .bind(i.host.id)
445 .bind(i.idx as i64)
446 .bind(i.timestamp as i64) .bind(&i.version)
448 .bind(&i.tag)
449 .bind(&i.data.data)
450 .bind(&i.data.content_encryption_key)
451 .bind(user.id)
452 .execute(&mut *tx)
453 .await?;
454
455 if result.rows_affected() > 0 {
457 heads
458 .entry((i.host.id, &i.tag))
459 .and_modify(|e| {
460 if i.idx > *e {
461 *e = i.idx
462 }
463 })
464 .or_insert(i.idx);
465 }
466 }
467
468 for ((host, tag), idx) in heads {
470 sqlx::query(
471 "insert into store_idx_cache
472 (user_id, host, tag, idx)
473 values ($1, $2, $3, $4)
474 on conflict(user_id, host, tag) do update set idx = greatest(store_idx_cache.idx, $4)
475 ",
476 )
477 .bind(user.id)
478 .bind(host)
479 .bind(tag)
480 .bind(idx as i64)
481 .execute(&mut *tx)
482 .await
483 ?;
484 }
485
486 tx.commit().await?;
487
488 Ok(())
489 }
490
491 #[instrument(skip_all)]
492 async fn next_records(
493 &self,
494 user: &User,
495 host: HostId,
496 tag: String,
497 start: Option<RecordIdx>,
498 count: u64,
499 ) -> DbResult<Vec<Record<EncryptedData>>> {
500 tracing::debug!("{:?} - {:?} - {:?}", host, tag, start);
501 let start = start.unwrap_or(0);
502
503 let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(
504 "select client_id, host, idx, timestamp, version, tag, data, cek from store
505 where user_id = $1
506 and tag = $2
507 and host = $3
508 and idx >= $4
509 order by idx asc
510 limit $5",
511 )
512 .bind(user.id)
513 .bind(tag.clone())
514 .bind(host)
515 .bind(start as i64)
516 .bind(count as i64)
517 .fetch_all(self.read_pool())
518 .await
519 .map_err(Into::into);
520
521 let ret = match records {
522 Ok(records) => {
523 let records: Vec<Record<EncryptedData>> = records
524 .into_iter()
525 .map(|f| {
526 let record: Record<EncryptedData> = f.into();
527 record
528 })
529 .collect();
530
531 records
532 }
533 Err(DbError::NotFound) => {
534 tracing::debug!("no records found in store: {:?}/{}", host, tag);
535 return Ok(vec![]);
536 }
537 Err(e) => return Err(e),
538 };
539
540 Ok(ret)
541 }
542
543 async fn status(&self, user: &User) -> DbResult<RecordStatus> {
544 const STATUS_SQL: &str =
545 "select host, tag, max(idx) from store where user_id = $1 group by host, tag";
546
547 let idx_cache_rollout = std::env::var("IDX_CACHE_ROLLOUT").unwrap_or("0".to_string());
554 let idx_cache_rollout = idx_cache_rollout.parse::<f64>().unwrap_or(0.0);
555 let use_idx_cache = rand::thread_rng().gen_bool(idx_cache_rollout / 100.0);
556
557 let mut res: Vec<(Uuid, String, i64)> = if use_idx_cache {
558 tracing::debug!("using idx cache for user {}", user.id);
559 sqlx::query_as("select host, tag, idx from store_idx_cache where user_id = $1")
560 .bind(user.id)
561 .fetch_all(self.read_pool())
562 .await?
563 } else {
564 tracing::debug!("using aggregate query for user {}", user.id);
565 sqlx::query_as(STATUS_SQL)
566 .bind(user.id)
567 .fetch_all(self.read_pool())
568 .await?
569 };
570
571 res.sort();
572
573 let mut status = RecordStatus::new();
574
575 for i in res.iter() {
576 status.set_raw(HostId(i.0), i.1.clone(), i.2 as u64);
577 }
578
579 Ok(status)
580 }
581}