1#![forbid(unsafe_code)]
4
5use std::{
6 path::{Path, PathBuf},
7 sync::{Arc, Mutex},
8};
9
10use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
16pub struct Balance {
17 pub amount: u64,
18 pub revision: u64,
19}
20
21#[derive(Debug, Error)]
23pub enum Error {
24 #[error("credit database failed: {0}")]
25 Database(#[from] rusqlite::Error),
26 #[error("credit writer lane is unavailable")]
27 WriterUnavailable,
28 #[error("user_id must not be empty")]
29 EmptyUserId,
30 #[error("credit amount is outside SQLite's supported range")]
31 AmountOutOfRange,
32}
33
34#[derive(Clone)]
36pub struct Credits {
37 path: Arc<PathBuf>,
38 writer: Arc<Mutex<Connection>>,
39}
40
41impl Credits {
42 pub fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
44 let path = path.as_ref().to_path_buf();
45 let connection = Connection::open(&path)?;
46 connection.execute_batch(
47 "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;
48 PRAGMA busy_timeout=37500;
49 CREATE TABLE IF NOT EXISTS balances(
50 user_id TEXT PRIMARY KEY,
51 amount INTEGER NOT NULL CHECK(amount >= 0)
52 ) STRICT;
53 CREATE TABLE IF NOT EXISTS metadata(
54 singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
55 revision INTEGER NOT NULL CHECK(revision >= 0)
56 ) STRICT;
57 INSERT OR IGNORE INTO metadata(singleton, revision) VALUES(1, 0);",
58 )?;
59 Ok(Self {
60 path: Arc::new(path),
61 writer: Arc::new(Mutex::new(connection)),
62 })
63 }
64
65 pub fn balance(&self, user_id: &str) -> Result<Balance, Error> {
67 validate_user(user_id)?;
68 let connection = self.reader()?;
69 let amount = connection
70 .query_row(
71 "SELECT amount FROM balances WHERE user_id=?1",
72 [user_id],
73 |row| row.get::<_, i64>(0),
74 )
75 .optional()?
76 .unwrap_or(0);
77 let revision = connection.query_row(
78 "SELECT revision FROM metadata WHERE singleton=1",
79 [],
80 |row| row.get::<_, i64>(0),
81 )?;
82 Ok(Balance {
83 amount: amount as u64,
84 revision: revision as u64,
85 })
86 }
87
88 pub fn award(&self, user_id: &str, amount: u64) -> Result<Balance, Error> {
90 validate_user(user_id)?;
91 let amount = i64::try_from(amount).map_err(|_| Error::AmountOutOfRange)?;
92 if amount == 0 {
93 return self.balance(user_id);
94 }
95 let mut writer = self.writer.lock().map_err(|_| Error::WriterUnavailable)?;
96 let transaction = writer.transaction_with_behavior(TransactionBehavior::Immediate)?;
97 let current = transaction
98 .query_row(
99 "SELECT amount FROM balances WHERE user_id=?1",
100 [user_id],
101 |row| row.get::<_, i64>(0),
102 )
103 .optional()?
104 .unwrap_or(0);
105 let total = current.checked_add(amount).ok_or(Error::AmountOutOfRange)?;
106 transaction.execute(
107 "INSERT INTO balances(user_id,amount) VALUES(?1,?2)
108 ON CONFLICT(user_id) DO UPDATE SET amount=excluded.amount",
109 params![user_id, total],
110 )?;
111 transaction.execute(
112 "UPDATE metadata SET revision=revision+1 WHERE singleton=1",
113 [],
114 )?;
115 let revision = transaction.query_row(
116 "SELECT revision FROM metadata WHERE singleton=1",
117 [],
118 |row| row.get::<_, i64>(0),
119 )?;
120 transaction.commit()?;
121 Ok(Balance {
122 amount: total as u64,
123 revision: revision as u64,
124 })
125 }
126
127 fn reader(&self) -> Result<Connection, Error> {
128 let connection = Connection::open(self.path.as_ref())?;
129 connection.execute_batch("PRAGMA query_only=ON; PRAGMA busy_timeout=37500;")?;
130 Ok(connection)
131 }
132}
133
134fn validate_user(user_id: &str) -> Result<(), Error> {
135 if user_id.trim().is_empty() {
136 Err(Error::EmptyUserId)
137 } else {
138 Ok(())
139 }
140}