mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-03-26 22:23:14 +00:00
Persist identity (#67)
* feat: storage for conversations * fix: db types conversion * feat: run migrations from sql files * feat: persist identity * fix: revert double ratchet storage refactor * fix: clean * refactor: use result wrapper for ffi * refactor: uniform storage error into chat error * fix: zeroize identity record * fix: zeroize for secret keys in db operations * fix: transactional sql migration * fix: remove destroy_string * refactor: inline identity record name clone
This commit is contained in:
parent
d006f20bce
commit
daeecbd679
3
Cargo.lock
generated
3
Cargo.lock
generated
@ -501,8 +501,11 @@ dependencies = [
|
|||||||
"prost",
|
"prost",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"safer-ffi",
|
"safer-ffi",
|
||||||
|
"storage",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -18,3 +18,8 @@ rand_core = { version = "0.6" }
|
|||||||
safer-ffi = "0.1.13"
|
safer-ffi = "0.1.13"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] }
|
x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] }
|
||||||
|
storage = { path = "../storage" }
|
||||||
|
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@ -13,9 +13,12 @@ use safer_ffi::{
|
|||||||
prelude::{c_slice, repr_c},
|
prelude::{c_slice, repr_c},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use storage::StorageConfig;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
context::{Context, Introduction},
|
context::{Context, Introduction},
|
||||||
errors::ChatError,
|
errors::ChatError,
|
||||||
|
ffi::utils::CResult,
|
||||||
types::ContentData,
|
types::ContentData,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -54,6 +57,41 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box<ContextHandle> {
|
|||||||
Box::new(ContextHandle(Context::new_with_name(&*name))).into()
|
Box::new(ContextHandle(Context::new_with_name(&*name))).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a new libchat Context with file-based persistent storage.
|
||||||
|
///
|
||||||
|
/// The identity will be loaded from storage if it exists, or created and saved if not.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - name: Friendly name for the identity (used if creating new identity)
|
||||||
|
/// - db_path: Path to the SQLite database file
|
||||||
|
/// - db_secret: Secret key for encrypting the database
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// CResult with context handle on success, or error string on failure.
|
||||||
|
/// On success, the context handle must be freed with `destroy_context()` after usage.
|
||||||
|
/// On error, the error string must be freed with `destroy_string()` after usage.
|
||||||
|
#[ffi_export]
|
||||||
|
pub fn create_context_with_storage(
|
||||||
|
name: repr_c::String,
|
||||||
|
db_path: repr_c::String,
|
||||||
|
db_secret: repr_c::String,
|
||||||
|
) -> CResult<repr_c::Box<ContextHandle>, repr_c::String> {
|
||||||
|
let config = StorageConfig::Encrypted {
|
||||||
|
path: db_path.to_string(),
|
||||||
|
key: db_secret.to_string(),
|
||||||
|
};
|
||||||
|
match Context::open(&*name, config) {
|
||||||
|
Ok(ctx) => CResult {
|
||||||
|
ok: Some(Box::new(ContextHandle(ctx)).into()),
|
||||||
|
err: None,
|
||||||
|
},
|
||||||
|
Err(e) => CResult {
|
||||||
|
ok: None,
|
||||||
|
err: Some(e.to_string().into()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the friendly name of the contexts installation.
|
/// Returns the friendly name of the contexts installation.
|
||||||
///
|
///
|
||||||
#[ffi_export]
|
#[ffi_export]
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use storage::StorageConfig;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
conversation::{ConversationId, ConversationStore, Convo, Id},
|
conversation::{ConversationId, ConversationStore, Convo, Id},
|
||||||
errors::ChatError,
|
errors::ChatError,
|
||||||
identity::Identity,
|
identity::Identity,
|
||||||
inbox::Inbox,
|
inbox::Inbox,
|
||||||
proto::{EncryptedPayload, EnvelopeV1, Message},
|
proto::{EncryptedPayload, EnvelopeV1, Message},
|
||||||
|
storage::ChatStorage,
|
||||||
types::{AddressedEnvelope, ContentData},
|
types::{AddressedEnvelope, ContentData},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,17 +21,44 @@ pub struct Context {
|
|||||||
_identity: Rc<Identity>,
|
_identity: Rc<Identity>,
|
||||||
store: ConversationStore,
|
store: ConversationStore,
|
||||||
inbox: Inbox,
|
inbox: Inbox,
|
||||||
|
#[allow(dead_code)] // Will be used for conversation persistence
|
||||||
|
storage: ChatStorage,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
pub fn new_with_name(name: impl Into<String>) -> Self {
|
/// Opens or creates a Context with the given storage configuration.
|
||||||
let identity = Rc::new(Identity::new(name));
|
///
|
||||||
let inbox = Inbox::new(Rc::clone(&identity)); //
|
/// If an identity exists in storage, it will be restored.
|
||||||
Self {
|
/// Otherwise, a new identity will be created with the given name and saved.
|
||||||
|
pub fn open(name: impl Into<String>, config: StorageConfig) -> Result<Self, ChatError> {
|
||||||
|
let mut storage = ChatStorage::new(config)?;
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
// Load or create identity
|
||||||
|
let identity = if let Some(identity) = storage.load_identity()? {
|
||||||
|
identity
|
||||||
|
} else {
|
||||||
|
let identity = Identity::new(&name);
|
||||||
|
storage.save_identity(&identity)?;
|
||||||
|
identity
|
||||||
|
};
|
||||||
|
|
||||||
|
let identity = Rc::new(identity);
|
||||||
|
let inbox = Inbox::new(Rc::clone(&identity));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
_identity: identity,
|
_identity: identity,
|
||||||
store: ConversationStore::new(),
|
store: ConversationStore::new(),
|
||||||
inbox,
|
inbox,
|
||||||
}
|
storage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new in-memory Context (for testing).
|
||||||
|
///
|
||||||
|
/// Uses in-memory SQLite database. Each call creates a new isolated database.
|
||||||
|
pub fn new_with_name(name: impl Into<String>) -> Self {
|
||||||
|
Self::open(name, StorageConfig::InMemory).expect("in-memory storage should not fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn installation_name(&self) -> &str {
|
pub fn installation_name(&self) -> &str {
|
||||||
@ -195,4 +225,31 @@ mod tests {
|
|||||||
send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content);
|
send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_persistence() {
|
||||||
|
// Use file-based storage to test real persistence
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir
|
||||||
|
.path()
|
||||||
|
.join("test_identity.db")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let config = StorageConfig::File(db_path);
|
||||||
|
|
||||||
|
// Create context - this should create and save a new identity
|
||||||
|
let ctx1 = Context::open("alice", config.clone()).unwrap();
|
||||||
|
let pubkey1 = ctx1._identity.public_key();
|
||||||
|
let name1 = ctx1.installation_name().to_string();
|
||||||
|
|
||||||
|
// Drop and reopen - should load the same identity
|
||||||
|
drop(ctx1);
|
||||||
|
let ctx2 = Context::open("alice", config).unwrap();
|
||||||
|
let pubkey2 = ctx2._identity.public_key();
|
||||||
|
let name2 = ctx2.installation_name().to_string();
|
||||||
|
|
||||||
|
// Identity should be the same
|
||||||
|
assert_eq!(pubkey1, pubkey2, "public key should persist");
|
||||||
|
assert_eq!(name1, name2, "name should persist");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
pub use thiserror::Error;
|
pub use thiserror::Error;
|
||||||
|
|
||||||
|
use storage::StorageError;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ChatError {
|
pub enum ChatError {
|
||||||
#[error("protocol error: {0:?}")]
|
#[error("protocol error: {0:?}")]
|
||||||
@ -20,6 +22,8 @@ pub enum ChatError {
|
|||||||
BadParsing(&'static str),
|
BadParsing(&'static str),
|
||||||
#[error("convo with id: {0} was not found")]
|
#[error("convo with id: {0} was not found")]
|
||||||
NoConvo(String),
|
NoConvo(String),
|
||||||
|
#[error("storage error: {0}")]
|
||||||
|
Storage(#[from] StorageError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
|
|||||||
1
conversations/src/ffi/mod.rs
Normal file
1
conversations/src/ffi/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod utils;
|
||||||
8
conversations/src/ffi/utils.rs
Normal file
8
conversations/src/ffi/utils.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
use safer_ffi::prelude::*;
|
||||||
|
|
||||||
|
#[derive_ReprC]
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct CResult<T: ReprC, Err: ReprC> {
|
||||||
|
pub ok: Option<T>,
|
||||||
|
pub err: Option<Err>,
|
||||||
|
}
|
||||||
@ -24,6 +24,13 @@ impl Identity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_secret(name: impl Into<String>, secret: PrivateKey) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn public_key(&self) -> PublicKey {
|
pub fn public_key(&self) -> PublicKey {
|
||||||
PublicKey::from(&self.secret)
|
PublicKey::from(&self.secret)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,11 @@ mod context;
|
|||||||
mod conversation;
|
mod conversation;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod errors;
|
mod errors;
|
||||||
|
mod ffi;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod inbox;
|
mod inbox;
|
||||||
mod proto;
|
mod proto;
|
||||||
|
mod storage;
|
||||||
mod types;
|
mod types;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
|||||||
111
conversations/src/storage/db.rs
Normal file
111
conversations/src/storage/db.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//! Chat-specific storage implementation.
|
||||||
|
|
||||||
|
use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params};
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
use super::migrations;
|
||||||
|
use super::types::IdentityRecord;
|
||||||
|
use crate::identity::Identity;
|
||||||
|
|
||||||
|
/// Chat-specific storage operations.
|
||||||
|
///
|
||||||
|
/// This struct wraps a SqliteDb and provides domain-specific
|
||||||
|
/// storage operations for chat state (identity, inbox keys, chat metadata).
|
||||||
|
///
|
||||||
|
/// Note: Ratchet state persistence is delegated to double_ratchets::RatchetStorage.
|
||||||
|
pub struct ChatStorage {
|
||||||
|
db: SqliteDb,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatStorage {
|
||||||
|
/// Creates a new ChatStorage with the given configuration.
|
||||||
|
pub fn new(config: StorageConfig) -> Result<Self, StorageError> {
|
||||||
|
let db = SqliteDb::new(config)?;
|
||||||
|
Self::run_migrations(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies all migrations and returns the storage instance.
|
||||||
|
fn run_migrations(mut db: SqliteDb) -> Result<Self, StorageError> {
|
||||||
|
migrations::apply_migrations(db.connection_mut())?;
|
||||||
|
Ok(Self { db })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Identity Operations ====================
|
||||||
|
|
||||||
|
/// Saves the identity (secret key).
|
||||||
|
///
|
||||||
|
/// Note: The secret key bytes are explicitly zeroized after use to minimize
|
||||||
|
/// the time sensitive data remains in stack memory.
|
||||||
|
pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> {
|
||||||
|
let mut secret_bytes = identity.secret().DANGER_to_bytes();
|
||||||
|
let result = self.db.connection().execute(
|
||||||
|
"INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)",
|
||||||
|
params![identity.get_name(), secret_bytes.as_slice()],
|
||||||
|
);
|
||||||
|
secret_bytes.zeroize();
|
||||||
|
result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the identity if it exists.
|
||||||
|
///
|
||||||
|
/// Note: Secret key bytes are zeroized after being copied into IdentityRecord,
|
||||||
|
/// which handles its own zeroization via ZeroizeOnDrop.
|
||||||
|
pub fn load_identity(&self) -> Result<Option<Identity>, StorageError> {
|
||||||
|
let mut stmt = self
|
||||||
|
.db
|
||||||
|
.connection()
|
||||||
|
.prepare("SELECT name, secret_key FROM identity WHERE id = 1")?;
|
||||||
|
|
||||||
|
let result = stmt.query_row([], |row| {
|
||||||
|
let name: String = row.get(0)?;
|
||||||
|
let secret_key: Vec<u8> = row.get(1)?;
|
||||||
|
Ok((name, secret_key))
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((name, mut secret_key_vec)) => {
|
||||||
|
let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into();
|
||||||
|
let bytes = match bytes {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => {
|
||||||
|
secret_key_vec.zeroize();
|
||||||
|
return Err(StorageError::InvalidData(
|
||||||
|
"Invalid secret key length".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
secret_key_vec.zeroize();
|
||||||
|
let record = IdentityRecord {
|
||||||
|
name,
|
||||||
|
secret_key: bytes,
|
||||||
|
};
|
||||||
|
Ok(Some(Identity::from(record)))
|
||||||
|
}
|
||||||
|
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_identity_roundtrip() {
|
||||||
|
let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap();
|
||||||
|
|
||||||
|
// Initially no identity
|
||||||
|
assert!(storage.load_identity().unwrap().is_none());
|
||||||
|
|
||||||
|
// Save identity
|
||||||
|
let identity = Identity::new("default");
|
||||||
|
let pubkey = identity.public_key();
|
||||||
|
storage.save_identity(&identity).unwrap();
|
||||||
|
|
||||||
|
// Load identity
|
||||||
|
let loaded = storage.load_identity().unwrap().unwrap();
|
||||||
|
assert_eq!(loaded.public_key(), pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
conversations/src/storage/migrations.rs
Normal file
46
conversations/src/storage/migrations.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
//! Database migrations module.
|
||||||
|
//!
|
||||||
|
//! SQL migrations are embedded at compile time and applied in order.
|
||||||
|
//! Each migration is applied atomically within a transaction.
|
||||||
|
|
||||||
|
use storage::{Connection, StorageError};
|
||||||
|
|
||||||
|
/// Embeds and returns all migration SQL files in order.
|
||||||
|
pub fn get_migrations() -> Vec<(&'static str, &'static str)> {
|
||||||
|
vec![(
|
||||||
|
"001_initial_schema",
|
||||||
|
include_str!("migrations/001_initial_schema.sql"),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies all migrations to the database.
|
||||||
|
///
|
||||||
|
/// Uses a simple version tracking table to avoid re-running migrations.
|
||||||
|
pub fn apply_migrations(conn: &mut Connection) -> Result<(), StorageError> {
|
||||||
|
// Create migrations tracking table if it doesn't exist
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for (name, sql) in get_migrations() {
|
||||||
|
// Check if migration already applied
|
||||||
|
let already_applied: bool = conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM _migrations WHERE name = ?1)",
|
||||||
|
[name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !already_applied {
|
||||||
|
// Apply migration and record it atomically in a transaction
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute_batch(sql)?;
|
||||||
|
tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- Initial schema for chat storage
|
||||||
|
-- Migration: 001_initial_schema
|
||||||
|
|
||||||
|
-- Identity table (single row)
|
||||||
|
CREATE TABLE IF NOT EXISTS identity (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
secret_key BLOB NOT NULL
|
||||||
|
);
|
||||||
7
conversations/src/storage/mod.rs
Normal file
7
conversations/src/storage/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
//! Storage module for persisting chat state.
|
||||||
|
|
||||||
|
mod db;
|
||||||
|
mod migrations;
|
||||||
|
pub(crate) mod types;
|
||||||
|
|
||||||
|
pub(crate) use db::ChatStorage;
|
||||||
57
conversations/src/storage/types.rs
Normal file
57
conversations/src/storage/types.rs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
//! Storage record types for serialization/deserialization.
|
||||||
|
|
||||||
|
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||||
|
|
||||||
|
use crate::crypto::PrivateKey;
|
||||||
|
use crate::identity::Identity;
|
||||||
|
|
||||||
|
/// Record for storing identity (secret key).
|
||||||
|
/// Implements ZeroizeOnDrop to securely clear secret key from memory.
|
||||||
|
#[derive(Debug, Zeroize, ZeroizeOnDrop)]
|
||||||
|
pub struct IdentityRecord {
|
||||||
|
/// The identity name.
|
||||||
|
pub name: String,
|
||||||
|
/// The secret key bytes (32 bytes).
|
||||||
|
pub secret_key: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<IdentityRecord> for Identity {
|
||||||
|
fn from(record: IdentityRecord) -> Self {
|
||||||
|
let secret = PrivateKey::from(record.secret_key);
|
||||||
|
Identity::from_secret(record.name.clone(), secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_identity_record_zeroize() {
|
||||||
|
let secret_key = [0xAB_u8; 32];
|
||||||
|
let mut record = IdentityRecord {
|
||||||
|
name: "test".to_string(),
|
||||||
|
secret_key,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get a pointer to the secret key before zeroizing
|
||||||
|
let ptr = record.secret_key.as_ptr();
|
||||||
|
|
||||||
|
// Manually zeroize (simulates what ZeroizeOnDrop does)
|
||||||
|
record.zeroize();
|
||||||
|
|
||||||
|
// Verify the memory is zeroed
|
||||||
|
// SAFETY: ptr still points to valid memory within record
|
||||||
|
unsafe {
|
||||||
|
let slice = std::slice::from_raw_parts(ptr, 32);
|
||||||
|
assert!(slice.iter().all(|&b| b == 0), "secret_key should be zeroed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also verify via the struct field
|
||||||
|
assert!(
|
||||||
|
record.secret_key.iter().all(|&b| b == 0),
|
||||||
|
"secret_key field should be zeroed"
|
||||||
|
);
|
||||||
|
assert!(record.name.is_empty(), "name should be cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,10 @@ pub enum StorageError {
|
|||||||
/// Transaction error.
|
/// Transaction error.
|
||||||
#[error("transaction error: {0}")]
|
#[error("transaction error: {0}")]
|
||||||
Transaction(String),
|
Transaction(String),
|
||||||
|
|
||||||
|
/// Invalid data error.
|
||||||
|
#[error("invalid data: {0}")]
|
||||||
|
InvalidData(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<rusqlite::Error> for StorageError {
|
impl From<rusqlite::Error> for StorageError {
|
||||||
|
|||||||
@ -12,4 +12,4 @@ pub use errors::StorageError;
|
|||||||
pub use sqlite::{SqliteDb, StorageConfig};
|
pub use sqlite::{SqliteDb, StorageConfig};
|
||||||
|
|
||||||
// Re-export rusqlite types that domain crates will need
|
// Re-export rusqlite types that domain crates will need
|
||||||
pub use rusqlite::{Error as RusqliteError, Transaction, params};
|
pub use rusqlite::{Connection, Error as RusqliteError, Transaction, params};
|
||||||
|
|||||||
@ -66,6 +66,13 @@ impl SqliteDb {
|
|||||||
&self.conn
|
&self.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the underlying connection.
|
||||||
|
///
|
||||||
|
/// Use this for operations that require mutable access, such as transactions.
|
||||||
|
pub fn connection_mut(&mut self) -> &mut Connection {
|
||||||
|
&mut self.conn
|
||||||
|
}
|
||||||
|
|
||||||
/// Begins a transaction.
|
/// Begins a transaction.
|
||||||
pub fn transaction(&mut self) -> Result<rusqlite::Transaction<'_>, StorageError> {
|
pub fn transaction(&mut self) -> Result<rusqlite::Transaction<'_>, StorageError> {
|
||||||
Ok(self.conn.transaction()?)
|
Ok(self.conn.transaction()?)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user