#[cfg(all(feature = "native-sqlite", feature = "_has-encryption"))]
compile_error!(
"Features `native-sqlite` and `encryption`/`encryption-cc` are mutually exclusive.\n\
If you ran `cargo install`, use:\n \
cargo install dynoxide-rs --no-default-features --features encrypted-server\n\
If using as a library dependency, set `default-features = false` \
and enable only one backend."
);
#[cfg(all(feature = "encryption", feature = "encryption-cc"))]
compile_error!(
"Features `encryption` and `encryption-cc` are mutually exclusive. \
Use `encryption` for vendored OpenSSL or `encryption-cc` for Apple CommonCrypto."
);
#[cfg(all(feature = "encryption-cc", not(target_vendor = "apple")))]
compile_error!(
"The `encryption-cc` feature is intended for Apple platforms only (CommonCrypto). \
Use the `encryption` feature for vendored OpenSSL on non-Apple platforms."
);
#[cfg(not(any(
feature = "native-sqlite",
feature = "_has-encryption",
feature = "wasm-sqlite"
)))]
compile_error!(
"A storage backend feature must be enabled: `native-sqlite`, `encryption`, \
`encryption-cc`, or `wasm-sqlite`. Default features include `native-sqlite`. \
If you used `default-features = false`, add one of these features."
);
pub mod actions;
pub mod auth_material;
pub mod errors;
pub mod expressions;
#[cfg(feature = "import")]
pub mod import;
#[doc(hidden)]
pub mod macros;
#[cfg(feature = "mcp-server")]
pub mod mcp;
#[cfg(any(feature = "http-server", feature = "mcp-server"))]
pub(crate) mod net;
pub mod partiql;
pub mod schema;
pub(crate) mod serde_errors;
#[cfg(feature = "http-server")]
pub mod server;
#[cfg(feature = "mcp-server")]
pub(crate) mod snapshots;
pub mod storage;
pub mod storage_backend;
pub mod streams;
pub mod ttl;
pub mod types;
pub mod validation;
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
pub(crate) mod dynamo_ops;
#[cfg(any(feature = "wasm-sqlite", test))]
pub mod wasm_api;
#[cfg(feature = "wasm-harness")]
pub mod wasm_harness;
#[doc(hidden)]
pub use macros::ItemInsert;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use web_time::{Duration, Instant};
pub use errors::{DynoxideError, Result};
pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
pub use storage_backend::BackendError;
#[cfg(feature = "wasm-sqlite")]
pub use storage_backend::WasmBridgeBackend;
pub use types::{AttributeValue, ConversionError, Item};
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
pub record_streams: bool,
pub set_cached_at: bool,
}
#[derive(Debug, Clone)]
pub struct ImportResult {
pub items_imported: usize,
pub bytes_imported: usize,
}
type TokenSlot<T> = (Instant, u64, Option<T>);
type TokenCache<T> = HashMap<String, TokenSlot<T>>;
type TransactWriteTokenCache =
TokenCache<actions::transact_write_items::TransactWriteItemsResponse>;
type ExecuteTransactionTokenCache =
TokenCache<actions::execute_transaction::ExecuteTransactionResponse>;
#[derive(Default)]
pub struct TokenCaches {
#[cfg_attr(
not(any(feature = "native-sqlite", feature = "_has-encryption")),
allow(dead_code)
)]
transact_write: Mutex<TransactWriteTokenCache>,
execute_transaction: Mutex<ExecuteTransactionTokenCache>,
}
impl TokenCaches {
pub fn new() -> Self {
Self::default()
}
#[cfg(any(feature = "wasm-sqlite", test))]
pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
&self.execute_transaction
}
}
const MAX_TOKEN_LEN: usize = 36;
const TOKEN_EXPIRY_SECS: u64 = 600;
fn validate_token(token: Option<&str>) -> Result<()> {
match token {
Some(token) if token.len() > MAX_TOKEN_LEN => {
Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
)))
}
_ => Ok(()),
}
}
fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
use std::hash::{Hash, Hasher};
let normalised = serde_json::to_value(input)
.and_then(|v| serde_json::to_vec(&v))
.unwrap_or_default();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
normalised.hash(&mut hasher);
hasher.finish()
}
fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
cache
.lock()
.map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
}
fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
}
fn token_window() -> Duration {
Duration::from_secs(TOKEN_EXPIRY_SECS)
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
fn run_idempotent<T, H, E, R>(
cache: &Mutex<TokenCache<T>>,
token: Option<&str>,
hash_input: &H,
execute: E,
replay: R,
) -> Result<T>
where
T: Clone,
H: serde::Serialize,
E: FnOnce() -> Result<T>,
R: FnOnce(&T) -> T,
{
validate_token(token)?;
let Some(token) = token else {
return execute();
};
let hash = request_hash(hash_input);
let mut cache = lock_cache(cache)?;
evict_expired(&mut cache, token_window());
let cached = match cache.get(token) {
Some((_, cached_hash, _)) if *cached_hash != hash => {
return Err(DynoxideError::IdempotentParameterMismatchException(
"An error occurred (IdempotentParameterMismatchException)".to_string(),
));
}
Some((_, _, slot)) => slot.clone(),
None => None,
};
if let Some(cached) = cached {
drop(cache);
return Ok(replay(&cached));
}
let resp = execute()?;
cache.insert(
token.to_string(),
(Instant::now(), hash, Some(resp.clone())),
);
Ok(resp)
}
#[cfg(any(feature = "wasm-sqlite", test))]
const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
#[cfg(any(feature = "wasm-sqlite", test))]
enum TokenClaim<T> {
Marked(Instant),
InFlight,
Mismatch,
Hit(T),
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn lookup_or_claim<T: Clone>(
cache: &Mutex<TokenCache<T>>,
token: &str,
hash: u64,
) -> Result<TokenClaim<T>> {
lookup_or_claim_within(cache, token, hash, token_window())
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn lookup_or_claim_within<T: Clone>(
cache: &Mutex<TokenCache<T>>,
token: &str,
hash: u64,
window: Duration,
) -> Result<TokenClaim<T>> {
let mut cache = lock_cache(cache)?;
evict_expired(&mut cache, window);
Ok(match cache.get(token) {
Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
Some((_, _, None)) => TokenClaim::InFlight,
Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
None => {
let claimed_at = Instant::now();
cache.insert(token.to_string(), (claimed_at, hash, None));
TokenClaim::Marked(claimed_at)
}
})
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn record_complete<T>(
cache: &Mutex<TokenCache<T>>,
token: &str,
claimed_at: Instant,
hash: u64,
resp: T,
) -> Result<()> {
let mut cache = lock_cache(cache)?;
if still_ours(&cache, token, claimed_at) {
cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
}
Ok(())
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
let mut cache = lock_cache(cache)?;
if still_ours(&cache, token, claimed_at) {
cache.remove(token);
}
Ok(())
}
#[cfg(any(feature = "wasm-sqlite", test))]
async fn run_idempotent_async<T, H, F, R>(
cache: &Mutex<TokenCache<T>>,
token: Option<&str>,
hash_input: &H,
execute: F,
replay: R,
) -> Result<T>
where
T: Clone,
H: serde::Serialize,
F: std::future::Future<Output = Result<T>>,
R: FnOnce(&T) -> T,
{
validate_token(token)?;
let Some(token) = token else {
return execute.await;
};
let hash = request_hash(hash_input);
let claimed_at = match lookup_or_claim(cache, token, hash)? {
TokenClaim::Hit(cached) => return Ok(replay(&cached)),
TokenClaim::Mismatch => {
return Err(DynoxideError::IdempotentParameterMismatchException(
"An error occurred (IdempotentParameterMismatchException)".to_string(),
));
}
TokenClaim::InFlight => {
return Err(DynoxideError::InternalServerError(
IN_FLIGHT_MESSAGE.to_string(),
));
}
TokenClaim::Marked(claimed_at) => claimed_at,
};
match execute.await {
Ok(resp) => {
let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
Ok(resp)
}
Err(e) => {
let _ = clear_claim(cache, token, claimed_at);
Err(e)
}
}
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub type RusqliteBackend = storage::Storage;
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub type NativeDatabase = Database<RusqliteBackend>;
#[cfg(feature = "wasm-sqlite")]
pub type WasmDatabase = Database<WasmBridgeBackend>;
#[cfg(feature = "wasm-sqlite")]
pub const WASM_PREVIEW: bool = true;
#[cfg(not(feature = "wasm-sqlite"))]
pub const WASM_PREVIEW: bool = false;
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub struct Database<S = RusqliteBackend> {
inner: Arc<Mutex<S>>,
tokens: Arc<TokenCaches>,
}
#[cfg(all(
not(any(feature = "native-sqlite", feature = "_has-encryption")),
feature = "wasm-sqlite"
))]
use async_lock::Mutex as BackendMutex;
#[cfg(all(
not(any(feature = "native-sqlite", feature = "_has-encryption")),
not(feature = "wasm-sqlite")
))]
use std::sync::Mutex as BackendMutex;
#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
pub struct Database<S> {
inner: Arc<BackendMutex<S>>,
tokens: Arc<TokenCaches>,
}
impl<S> Clone for Database<S> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
tokens: Arc::clone(&self.tokens),
}
}
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
impl Database<RusqliteBackend> {
pub fn new(path: &str) -> Result<Self> {
let storage = storage::Storage::new(path)?;
Ok(Self {
inner: Arc::new(Mutex::new(storage)),
tokens: Arc::new(TokenCaches::new()),
})
}
#[cfg(feature = "_has-encryption")]
pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(DynoxideError::ValidationException(
"Encryption key must be a 64-character hex string (32 bytes)".to_string(),
));
}
let storage = storage::Storage::new_encrypted(path, key)?;
Ok(Self {
inner: Arc::new(Mutex::new(storage)),
tokens: Arc::new(TokenCaches::new()),
})
}
pub fn memory() -> Result<Self> {
let storage = storage::Storage::memory()?;
Ok(Self {
inner: Arc::new(Mutex::new(storage)),
tokens: Arc::new(TokenCaches::new()),
})
}
pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&storage::Storage) -> Result<T>,
{
let guard = self
.inner
.lock()
.map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
f(&guard)
}
pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut storage::Storage) -> Result<T>,
{
let mut guard = self
.inner
.lock()
.map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
f(&mut guard)
}
pub fn create_table(
&self,
request: actions::create_table::CreateTableRequest,
) -> Result<actions::create_table::CreateTableResponse> {
self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
}
pub fn delete_table(
&self,
request: actions::delete_table::DeleteTableRequest,
) -> Result<actions::delete_table::DeleteTableResponse> {
self.with_storage(|s| pollster::block_on(actions::delete_table::execute(s, request)))
}
pub fn describe_table(
&self,
request: actions::describe_table::DescribeTableRequest,
) -> Result<actions::describe_table::DescribeTableResponse> {
self.with_storage(|s| pollster::block_on(actions::describe_table::execute(s, request)))
}
pub fn update_table(
&self,
request: actions::update_table::UpdateTableRequest,
) -> Result<actions::update_table::UpdateTableResponse> {
self.with_storage(|s| pollster::block_on(actions::update_table::execute(s, request)))
}
pub fn list_tables(
&self,
request: actions::list_tables::ListTablesRequest,
) -> Result<actions::list_tables::ListTablesResponse> {
self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
}
pub fn tag_resource(
&self,
request: actions::tag_resource::TagResourceRequest,
) -> Result<actions::tag_resource::TagResourceResponse> {
self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
}
pub fn untag_resource(
&self,
request: actions::untag_resource::UntagResourceRequest,
) -> Result<actions::untag_resource::UntagResourceResponse> {
self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
}
pub fn list_tags_of_resource(
&self,
request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
self.with_storage(|s| {
pollster::block_on(actions::list_tags_of_resource::execute(s, request))
})
}
pub fn put_item(
&self,
request: actions::put_item::PutItemRequest,
) -> Result<actions::put_item::PutItemResponse> {
self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
}
pub fn get_item(
&self,
request: actions::get_item::GetItemRequest,
) -> Result<actions::get_item::GetItemResponse> {
self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
}
pub fn delete_item(
&self,
request: actions::delete_item::DeleteItemRequest,
) -> Result<actions::delete_item::DeleteItemResponse> {
self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
}
pub fn update_item(
&self,
request: actions::update_item::UpdateItemRequest,
) -> Result<actions::update_item::UpdateItemResponse> {
self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
}
pub fn batch_get_item(
&self,
request: actions::batch_get_item::BatchGetItemRequest,
) -> Result<actions::batch_get_item::BatchGetItemResponse> {
self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
}
pub fn batch_write_item(
&self,
request: actions::batch_write_item::BatchWriteItemRequest,
) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
}
pub fn import_items(
&self,
table_name: &str,
items: Vec<Item>,
options: ImportOptions,
) -> Result<ImportResult> {
self.with_storage(|s| {
pollster::block_on(actions::import_items::execute(
s, table_name, items, &options,
))
})
}
#[cfg(feature = "import")]
pub(crate) fn import_items_fresh(
&self,
table_name: &str,
items: Vec<Item>,
options: ImportOptions,
) -> Result<ImportResult> {
self.with_storage(|s| {
pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
s, table_name, items, &options,
))
})
}
pub fn enable_bulk_loading(&self) -> Result<()> {
self.with_storage(|s| s.enable_bulk_loading())
}
pub fn disable_bulk_loading(&self) -> Result<()> {
self.with_storage(|s| s.disable_bulk_loading())
}
pub fn query(
&self,
request: actions::query::QueryRequest,
) -> Result<actions::query::QueryResponse> {
self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
}
pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
}
pub fn transact_write_items(
&self,
request: actions::transact_write_items::TransactWriteItemsRequest,
) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
run_idempotent(
&self.tokens.transact_write,
request.client_request_token.as_deref(),
&request.transact_items,
|| {
self.with_storage(|s| {
pollster::block_on(actions::transact_write_items::execute(s, request.clone()))
})
},
|cached| {
actions::transact_write_items::replay_response(
&request.transact_items,
&request.return_consumed_capacity,
cached.item_collection_metrics.clone(),
)
},
)
}
pub fn transact_get_items(
&self,
request: actions::transact_get_items::TransactGetItemsRequest,
) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
}
pub fn list_streams(
&self,
request: actions::list_streams::ListStreamsRequest,
) -> Result<actions::list_streams::ListStreamsResponse> {
self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
}
pub fn describe_stream(
&self,
request: actions::describe_stream::DescribeStreamRequest,
) -> Result<actions::describe_stream::DescribeStreamResponse> {
self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
}
pub fn get_shard_iterator(
&self,
request: actions::get_shard_iterator::GetShardIteratorRequest,
) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
}
pub fn get_records(
&self,
request: actions::get_records::GetRecordsRequest,
) -> Result<actions::get_records::GetRecordsResponse> {
self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
}
pub fn update_time_to_live(
&self,
request: actions::update_time_to_live::UpdateTimeToLiveRequest,
) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
}
pub fn describe_time_to_live(
&self,
request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
self.with_storage(|s| {
pollster::block_on(actions::describe_time_to_live::execute(s, request))
})
}
pub fn sweep_ttl(&self) -> Result<usize> {
self.with_storage(|s| pollster::block_on(ttl::sweep_expired_items(s)))
}
pub fn execute_statement(
&self,
request: actions::execute_statement::ExecuteStatementRequest,
) -> Result<actions::execute_statement::ExecuteStatementResponse> {
self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
}
pub fn execute_transaction(
&self,
request: actions::execute_transaction::ExecuteTransactionRequest,
) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
run_idempotent(
&self.tokens.execute_transaction,
request.client_request_token.as_deref(),
&request.transact_statements,
|| {
self.with_storage(|s| {
pollster::block_on(actions::execute_transaction::execute(s, request.clone()))
})
},
|cached| {
actions::execute_transaction::replay_response(
&request.transact_statements,
&request.return_consumed_capacity,
cached.responses.clone(),
)
},
)
}
pub fn batch_execute_statement(
&self,
request: actions::batch_execute_statement::BatchExecuteStatementRequest,
) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
self.with_storage(|s| {
pollster::block_on(actions::batch_execute_statement::execute(s, request))
})
}
pub fn touch_cached_at(
&self,
table_name: &str,
pk: &str,
sk: &str,
timestamp: f64,
) -> Result<()> {
self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
}
pub fn get_lru_items(
&self,
table_name: &str,
limit: usize,
) -> Result<Vec<(String, String, i64)>> {
self.with_storage(|s| s.get_lru_items(table_name, limit))
}
pub fn db_path(&self) -> Result<Option<String>> {
self.with_storage(|s| Ok(s.db_path()))
}
pub fn db_size_bytes(&self) -> Result<u64> {
self.with_storage(|s| s.db_size_bytes())
}
pub fn table_count(&self) -> Result<usize> {
self.with_storage(|s| s.table_count())
}
pub fn table_stats(&self) -> Result<Vec<TableStats>> {
self.with_storage(|s| s.table_stats())
}
pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
self.with_storage(|s| s.get_table_metadata(table_name))
}
pub fn database_info(&self) -> Result<DatabaseInfo> {
self.with_storage(|s| s.database_info())
}
pub fn vacuum(&self) -> Result<()> {
self.with_storage(|s| s.vacuum())
}
pub fn vacuum_into(&self, path: &str) -> Result<()> {
self.with_storage(|s| s.vacuum_into(path))
}
pub fn restore_from(&self, path: &str) -> Result<()> {
self.with_storage_mut(|s| s.restore_from(path))
}
#[cfg(feature = "mcp-server")]
pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
self.with_storage(|s| s.backup_to_memory())
}
#[cfg(feature = "mcp-server")]
pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
self.with_storage_mut(|s| s.restore_from_connection(source))
}
}
#[cfg(feature = "wasm-sqlite")]
impl Database<WasmBridgeBackend> {
pub async fn open(name: &str) -> Result<Self> {
Self::open_with(name, false).await
}
pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
let backend = WasmBridgeBackend::open_with(name, ephemeral)
.await
.map_err(DynoxideError::from)?;
Ok(Self {
inner: Arc::new(BackendMutex::new(backend)),
tokens: Arc::new(TokenCaches::new()),
})
}
pub async fn persistence_mode(&self) -> String {
self.backend().await.persistence_mode().to_string()
}
pub async fn close(&self) -> Result<()> {
self.backend()
.await
.close()
.await
.map_err(DynoxideError::from)
}
pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
self.inner.lock().await
}
pub(crate) fn token_caches(&self) -> &TokenCaches {
&self.tokens
}
pub async fn create_table(
&self,
request: actions::create_table::CreateTableRequest,
) -> Result<actions::create_table::CreateTableResponse> {
let backend = self.backend().await;
actions::create_table::execute(&*backend, request).await
}
pub async fn delete_table(
&self,
request: actions::delete_table::DeleteTableRequest,
) -> Result<actions::delete_table::DeleteTableResponse> {
let backend = self.backend().await;
actions::delete_table::execute(&*backend, request).await
}
pub async fn describe_table(
&self,
request: actions::describe_table::DescribeTableRequest,
) -> Result<actions::describe_table::DescribeTableResponse> {
let backend = self.backend().await;
actions::describe_table::execute(&*backend, request).await
}
pub async fn list_tables(
&self,
request: actions::list_tables::ListTablesRequest,
) -> Result<actions::list_tables::ListTablesResponse> {
let backend = self.backend().await;
actions::list_tables::execute(&*backend, request).await
}
pub async fn put_item(
&self,
request: actions::put_item::PutItemRequest,
) -> Result<actions::put_item::PutItemResponse> {
let backend = self.backend().await;
actions::put_item::execute(&*backend, request).await
}
pub async fn get_item(
&self,
request: actions::get_item::GetItemRequest,
) -> Result<actions::get_item::GetItemResponse> {
let backend = self.backend().await;
actions::get_item::execute(&*backend, request).await
}
pub async fn delete_item(
&self,
request: actions::delete_item::DeleteItemRequest,
) -> Result<actions::delete_item::DeleteItemResponse> {
let backend = self.backend().await;
actions::delete_item::execute(&*backend, request).await
}
pub async fn query(
&self,
request: actions::query::QueryRequest,
) -> Result<actions::query::QueryResponse> {
let backend = self.backend().await;
actions::query::execute(&*backend, request).await
}
pub async fn scan(
&self,
request: actions::scan::ScanRequest,
) -> Result<actions::scan::ScanResponse> {
let backend = self.backend().await;
actions::scan::execute(&*backend, request).await
}
}
#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
mod tests {
use super::*;
#[test]
fn test_database_memory() {
let db = Database::memory().unwrap();
let _db2 = db.clone();
}
#[test]
fn test_database_with_storage() {
let db = Database::memory().unwrap();
let tables = db.with_storage(|s| s.list_table_names()).unwrap();
assert!(tables.is_empty());
}
#[test]
fn test_database_thread_safe() {
let db = Database::memory().unwrap();
let db2 = db.clone();
let handle =
std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
let tables = handle.join().unwrap();
assert!(tables.is_empty());
}
#[test]
fn test_native_database_alias_round_trips() {
let db: NativeDatabase = Database::memory().unwrap();
db.create_table(actions::create_table::CreateTableRequest {
table_name: "tbl".to_string(),
key_schema: vec![types::KeySchemaElement {
attribute_name: "pk".to_string(),
key_type: types::KeyType::HASH,
}],
attribute_definitions: vec![types::AttributeDefinition {
attribute_name: "pk".to_string(),
attribute_type: types::ScalarAttributeType::S,
}],
..Default::default()
})
.unwrap();
let mut item = HashMap::new();
item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
db.put_item(actions::put_item::PutItemRequest {
table_name: "tbl".to_string(),
item,
..Default::default()
})
.unwrap();
let mut key = HashMap::new();
key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
let got = db
.get_item(actions::get_item::GetItemRequest {
table_name: "tbl".to_string(),
key,
..Default::default()
})
.unwrap();
assert_eq!(
got.item.unwrap().get("pk"),
Some(&AttributeValue::S("a".to_string()))
);
}
}
#[cfg(test)]
mod idempotency_tests {
use super::*;
use std::cell::Cell;
const KEY: &str = "statements";
const TOKEN: &str = "tok";
fn cache() -> Mutex<TokenCache<u32>> {
Mutex::new(HashMap::new())
}
fn hash() -> u64 {
request_hash(&KEY)
}
fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
where
F: std::future::Future<Output = Result<u32>>,
{
pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
}
#[test]
fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
let cache = cache();
let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("the first caller should have claimed the token");
};
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Hit(7)
));
}
#[test]
fn only_one_caller_can_claim_a_token() {
use std::sync::Barrier;
const N: usize = 16;
let cache = cache();
let barrier = Barrier::new(N);
let marked = std::thread::scope(|scope| {
let handles: Vec<_> = (0..N)
.map(|_| {
scope.spawn(|| {
barrier.wait();
matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Marked(_)
)
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().unwrap())
.filter(|claimed| *claimed)
.count()
});
assert_eq!(marked, 1);
}
#[test]
fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
let cache = cache();
let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("the first caller should have claimed the token");
};
let TokenClaim::Marked(second) =
lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
else {
panic!("the expired claim should have been re-issued");
};
assert_ne!(first, second);
record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
clear_claim(&cache, TOKEN, first).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Hit(2)
));
}
#[test]
fn a_different_request_under_the_same_token_mismatches_in_both_states() {
let other = request_hash(&"different");
let cache = cache();
let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("expected a free slot to claim");
};
assert!(matches!(
lookup_or_claim(&cache, TOKEN, other).unwrap(),
TokenClaim::Mismatch
));
record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, other).unwrap(),
TokenClaim::Mismatch
));
}
#[test]
fn the_claim_lands_before_the_call_is_polled() {
let cache = cache();
let claimed_first = Cell::new(false);
let out = drive(&cache, Some(TOKEN), async {
let slots = cache
.try_lock()
.expect("the cache lock must be released before the call runs");
claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
Ok(7)
});
assert_eq!(out.unwrap(), 7);
assert!(claimed_first.get());
}
#[test]
fn a_caller_arriving_under_a_live_claim_does_not_execute() {
let cache = cache();
let second_ran = Cell::new(false);
let out = drive(&cache, Some(TOKEN), async {
let second = drive(&cache, Some(TOKEN), async {
second_ran.set(true);
Ok(0)
});
assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
Ok(1)
});
assert_eq!(out.unwrap(), 1);
assert!(!second_ran.get(), "the second call must not run the work");
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Hit(1)
));
}
#[test]
fn the_synchronous_driver_treats_a_live_claim_as_absent() {
let cache = cache();
lookup_or_claim(&cache, TOKEN, hash()).unwrap();
let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
assert_eq!(
out, 9,
"a claimed slot is not treated as a replayable result"
);
}
#[test]
fn a_settled_token_replays_without_re_executing() {
let cache = cache();
let runs = Cell::new(0);
for _ in 0..2 {
let out = drive(&cache, Some(TOKEN), async {
runs.set(runs.get() + 1);
Ok(7)
});
assert_eq!(out.unwrap(), 7);
}
assert_eq!(runs.get(), 1);
}
#[test]
fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
let cache = cache();
let runs = Cell::new(0);
let first = drive(&cache, Some(TOKEN), async {
runs.set(runs.get() + 1);
Err(DynoxideError::ValidationException("no".into()))
});
assert!(first.is_err());
assert!(
cache.lock().unwrap().is_empty(),
"the claim must be released"
);
let second = drive(&cache, Some(TOKEN), async {
runs.set(runs.get() + 1);
Ok(7)
});
assert_eq!(second.unwrap(), 7);
assert_eq!(runs.get(), 2);
}
#[test]
fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
let cache = cache();
lookup_or_claim(&cache, TOKEN, hash()).unwrap();
assert!(matches!(
lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
TokenClaim::Marked(_)
));
}
#[test]
fn a_settled_token_stops_replaying_once_it_expires() {
let cache = cache();
let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("expected a free slot to claim");
};
record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
assert!(matches!(
lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
TokenClaim::Hit(7)
));
assert!(matches!(
lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
TokenClaim::Marked(_)
));
}
#[test]
fn concurrent_callers_under_one_token_execute_the_work_once() {
use std::sync::Barrier;
use std::sync::atomic::{AtomicUsize, Ordering};
const N: usize = 16;
let cache = cache();
let runs = AtomicUsize::new(0);
let barrier = Barrier::new(N);
let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..N)
.map(|_| {
scope.spawn(|| {
barrier.wait();
drive(&cache, Some(TOKEN), async {
runs.fetch_add(1, Ordering::SeqCst);
Ok(7)
})
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
for outcome in outcomes {
match outcome {
Ok(v) => assert_eq!(v, 7),
Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
}
}
}
#[test]
fn an_overlong_token_is_rejected_with_dynamodbs_message() {
let token = "x".repeat(MAX_TOKEN_LEN + 1);
let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
assert_eq!(
err.to_string(),
format!(
"1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
)
);
}
#[test]
fn a_tokenless_call_never_touches_the_cache() {
let cache = cache();
let runs = Cell::new(0);
for _ in 0..2 {
drive(&cache, None, async {
runs.set(runs.get() + 1);
Ok(7)
})
.unwrap();
}
assert_eq!(runs.get(), 2);
assert!(cache.lock().unwrap().is_empty());
}
}