Skip to main content

kcode_credits/
lib.rs

1//! Separate persistent gamification-credit balances for Kennedy.
2
3#![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/// A user's current credit amount and the database-wide change revision.
15#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
16pub struct Balance {
17    pub amount: u64,
18    pub revision: u64,
19}
20
21/// Credit storage or input failure.
22#[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/// Cloneable access to one credit database.
35#[derive(Clone)]
36pub struct Credits {
37    path: Arc<PathBuf>,
38    writer: Arc<Mutex<Connection>>,
39}
40
41impl Credits {
42    /// Opens or creates a credit database at `path`.
43    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=15000;
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    /// Returns the user's balance and global revision without changing state.
66    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    /// Atomically adds credits and returns the resulting balance.
89    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=15000;")?;
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}