kcode-credits 0.1.1

Kennedy's separate persistent gamification-credit balance capability
Documentation
//! Separate persistent gamification-credit balances for Kennedy.

#![forbid(unsafe_code)]

use std::{
    path::{Path, PathBuf},
    sync::{Arc, Mutex},
};

use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// A user's current credit amount and the database-wide change revision.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Balance {
    pub amount: u64,
    pub revision: u64,
}

/// Credit storage or input failure.
#[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,
}

/// Cloneable access to one credit database.
#[derive(Clone)]
pub struct Credits {
    path: Arc<PathBuf>,
    writer: Arc<Mutex<Connection>>,
}

impl Credits {
    /// Opens or creates a credit database at `path`.
    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)),
        })
    }

    /// Returns the user's balance and global revision without changing state.
    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,
        })
    }

    /// Atomically adds credits and returns the resulting balance.
    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(())
    }
}