#![forbid(unsafe_code)]
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Balance {
pub amount: u64,
pub revision: u64,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("credit database failed: {0}")]
Database(#[from] rusqlite::Error),
#[error("credit writer lane is unavailable")]
WriterUnavailable,
#[error("user_id must not be empty")]
EmptyUserId,
#[error("credit amount is outside SQLite's supported range")]
AmountOutOfRange,
}
#[derive(Clone)]
pub struct Credits {
path: Arc<PathBuf>,
writer: Arc<Mutex<Connection>>,
}
impl Credits {
pub fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
let path = path.as_ref().to_path_buf();
let connection = Connection::open(&path)?;
connection.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=37500;
CREATE TABLE IF NOT EXISTS balances(
user_id TEXT PRIMARY KEY,
amount INTEGER NOT NULL CHECK(amount >= 0)
) STRICT;
CREATE TABLE IF NOT EXISTS metadata(
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
revision INTEGER NOT NULL CHECK(revision >= 0)
) STRICT;
INSERT OR IGNORE INTO metadata(singleton, revision) VALUES(1, 0);",
)?;
Ok(Self {
path: Arc::new(path),
writer: Arc::new(Mutex::new(connection)),
})
}
pub fn balance(&self, user_id: &str) -> Result<Balance, Error> {
validate_user(user_id)?;
let connection = self.reader()?;
let amount = connection
.query_row(
"SELECT amount FROM balances WHERE user_id=?1",
[user_id],
|row| row.get::<_, i64>(0),
)
.optional()?
.unwrap_or(0);
let revision = connection.query_row(
"SELECT revision FROM metadata WHERE singleton=1",
[],
|row| row.get::<_, i64>(0),
)?;
Ok(Balance {
amount: amount as u64,
revision: revision as u64,
})
}
pub fn award(&self, user_id: &str, amount: u64) -> Result<Balance, Error> {
validate_user(user_id)?;
let amount = i64::try_from(amount).map_err(|_| Error::AmountOutOfRange)?;
if amount == 0 {
return self.balance(user_id);
}
let mut writer = self.writer.lock().map_err(|_| Error::WriterUnavailable)?;
let transaction = writer.transaction_with_behavior(TransactionBehavior::Immediate)?;
let current = transaction
.query_row(
"SELECT amount FROM balances WHERE user_id=?1",
[user_id],
|row| row.get::<_, i64>(0),
)
.optional()?
.unwrap_or(0);
let total = current.checked_add(amount).ok_or(Error::AmountOutOfRange)?;
transaction.execute(
"INSERT INTO balances(user_id,amount) VALUES(?1,?2)
ON CONFLICT(user_id) DO UPDATE SET amount=excluded.amount",
params![user_id, total],
)?;
transaction.execute(
"UPDATE metadata SET revision=revision+1 WHERE singleton=1",
[],
)?;
let revision = transaction.query_row(
"SELECT revision FROM metadata WHERE singleton=1",
[],
|row| row.get::<_, i64>(0),
)?;
transaction.commit()?;
Ok(Balance {
amount: total as u64,
revision: revision as u64,
})
}
fn reader(&self) -> Result<Connection, Error> {
let connection = Connection::open(self.path.as_ref())?;
connection.execute_batch("PRAGMA query_only=ON; PRAGMA busy_timeout=37500;")?;
Ok(connection)
}
}
fn validate_user(user_id: &str) -> Result<(), Error> {
if user_id.trim().is_empty() {
Err(Error::EmptyUserId)
} else {
Ok(())
}
}