use crate::error::{AlayaError, Result};
use crate::schema;
use rusqlite::Connection;
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(conn, f)))]
pub(crate) fn transact<F, T>(conn: &Connection, f: F) -> Result<T>
where
F: FnOnce(&rusqlite::Transaction) -> Result<T>,
{
let tx = schema::begin_immediate(conn)?;
let result = f(&tx)?;
tx.commit().with_context("commit")?;
Ok(result)
}
pub(crate) fn to_json<T: serde::Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(Into::into)
}
#[allow(clippy::manual_unwrap_or_default)] pub(crate) fn from_json_or_default<T: serde::de::DeserializeOwned + Default>(s: &str) -> T {
match serde_json::from_str(s) {
Ok(v) => v,
Err(_e) => {
#[cfg(feature = "tracing")]
tracing::warn!(input = s, error = %_e, "JSON parse failed, using default");
T::default()
}
}
}
pub(crate) trait ResultExt<T> {
fn with_context(self, ctx: &str) -> Result<T>;
}
impl<T> ResultExt<T> for std::result::Result<T, rusqlite::Error> {
fn with_context(self, ctx: &str) -> Result<T> {
self.map_err(|e| AlayaError::Db {
source: e,
context: ctx.to_string(),
})
}
}
impl<T> ResultExt<T> for Result<T> {
fn with_context(self, ctx: &str) -> Result<T> {
self.map_err(|e| match e {
AlayaError::Db { source, .. } => AlayaError::Db {
source,
context: ctx.to_string(),
},
other => other,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn now_returns_reasonable_timestamp() {
let ts = now();
assert!(ts > 1_704_067_200, "timestamp too old: {ts}");
assert!(ts < 4_102_444_800, "timestamp too far in future: {ts}");
}
#[test]
fn transact_commits_on_success() {
let conn = crate::schema::open_memory_db().unwrap();
let result = transact(&conn, |tx| {
tx.execute("INSERT INTO episodes (content, role, session_id, timestamp) VALUES ('test', 'user', 's1', 1000)", [])?;
Ok(42)
});
assert_eq!(result.unwrap(), 42);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM episodes", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn transact_rolls_back_on_error() {
let conn = crate::schema::open_memory_db().unwrap();
let result: crate::Result<()> = transact(&conn, |tx| {
tx.execute("INSERT INTO episodes (content, role, session_id, timestamp) VALUES ('test', 'user', 's1', 1000)", [])?;
Err(crate::AlayaError::InvalidInput("intentional".into()))
});
assert!(result.is_err());
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM episodes", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn to_json_serializes() {
let v = vec!["a", "b"];
let json = to_json(&v).unwrap();
assert_eq!(json, r#"["a","b"]"#);
}
#[test]
fn from_json_or_default_parses() {
let v: Vec<String> = from_json_or_default(r#"["a","b"]"#);
assert_eq!(v, vec!["a", "b"]);
}
#[test]
fn from_json_or_default_returns_default_on_bad_input() {
let v: Vec<String> = from_json_or_default("not json");
assert!(v.is_empty());
}
#[test]
fn result_ext_adds_context_to_rusqlite_error() {
let err: std::result::Result<(), rusqlite::Error> =
Err(rusqlite::Error::QueryReturnedNoRows);
let result: crate::Result<()> = err.with_context("test_operation");
match result.unwrap_err() {
crate::AlayaError::Db { context, .. } => assert_eq!(context, "test_operation"),
other => panic!("expected Db error, got: {other}"),
}
}
#[test]
fn result_ext_passes_through_ok() {
let ok: std::result::Result<i32, rusqlite::Error> = Ok(42);
assert_eq!(ok.with_context("ctx").unwrap(), 42);
}
#[test]
fn result_ext_rewraps_db_error_with_new_context() {
let err: crate::Result<()> = Err(crate::AlayaError::Db {
source: rusqlite::Error::QueryReturnedNoRows,
context: "old_context".to_string(),
});
let result = err.with_context("new_context");
match result.unwrap_err() {
crate::AlayaError::Db { context, .. } => assert_eq!(context, "new_context"),
other => panic!("expected Db error, got: {other}"),
}
}
#[test]
fn result_ext_passes_through_non_db_error() {
let err: crate::Result<()> = Err(crate::AlayaError::InvalidInput("original".into()));
let result = err.with_context("ignored_context");
match result.unwrap_err() {
crate::AlayaError::InvalidInput(msg) => assert_eq!(msg, "original"),
other => panic!("expected InvalidInput error, got: {other}"),
}
}
}