1#[cfg(all(feature = "native-sqlite", feature = "_has-encryption"))]
12compile_error!(
13 "Features `native-sqlite` and `encryption`/`encryption-cc` are mutually exclusive.\n\
14 If you ran `cargo install`, use:\n \
15 cargo install dynoxide-rs --no-default-features --features encrypted-server\n\
16 If using as a library dependency, set `default-features = false` \
17 and enable only one backend."
18);
19
20#[cfg(all(feature = "encryption", feature = "encryption-cc"))]
21compile_error!(
22 "Features `encryption` and `encryption-cc` are mutually exclusive. \
23 Use `encryption` for vendored OpenSSL or `encryption-cc` for Apple CommonCrypto."
24);
25
26#[cfg(all(feature = "encryption-cc", not(target_vendor = "apple")))]
27compile_error!(
28 "The `encryption-cc` feature is intended for Apple platforms only (CommonCrypto). \
29 Use the `encryption` feature for vendored OpenSSL on non-Apple platforms."
30);
31
32#[cfg(not(any(
33 feature = "native-sqlite",
34 feature = "_has-encryption",
35 feature = "wasm-sqlite"
36)))]
37compile_error!(
38 "A storage backend feature must be enabled: `native-sqlite`, `encryption`, \
39 `encryption-cc`, or `wasm-sqlite`. Default features include `native-sqlite`. \
40 If you used `default-features = false`, add one of these features."
41);
42
43pub mod actions;
44pub mod auth_material;
45pub mod errors;
46pub mod expressions;
47#[cfg(feature = "import")]
48pub mod import;
49#[doc(hidden)]
50pub mod macros;
51#[cfg(feature = "mcp-server")]
52pub mod mcp;
53#[cfg(any(feature = "http-server", feature = "mcp-server"))]
54pub(crate) mod net;
55pub mod partiql;
56pub mod schema;
57pub(crate) mod serde_errors;
61#[cfg(feature = "http-server")]
62pub mod server;
63#[cfg(feature = "mcp-server")]
64pub(crate) mod snapshots;
65pub mod storage;
66pub mod storage_backend;
67pub mod streams;
68pub mod ttl;
69pub mod types;
70pub mod validation;
71#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
75pub(crate) mod dynamo_ops;
76#[cfg(any(feature = "wasm-sqlite", test))]
81pub mod wasm_api;
82#[cfg(feature = "wasm-harness")]
83pub mod wasm_harness;
84
85#[doc(hidden)]
86pub use macros::ItemInsert;
87
88use std::collections::HashMap;
89use std::sync::{Arc, Mutex};
90use web_time::{Duration, Instant};
91
92pub use errors::{DynoxideError, Result};
93pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
94pub use storage_backend::BackendError;
95#[cfg(feature = "wasm-sqlite")]
96pub use storage_backend::WasmBridgeBackend;
97pub use types::{AttributeValue, ConversionError, Item};
98
99#[derive(Debug, Clone, Default)]
101pub struct ImportOptions {
102 pub record_streams: bool,
104 pub set_cached_at: bool,
106}
107
108#[derive(Debug, Clone)]
110pub struct ImportResult {
111 pub items_imported: usize,
113 pub bytes_imported: usize,
115}
116
117type TokenSlot<T> = (Instant, u64, Option<T>);
125
126type TokenCache<T> = HashMap<String, TokenSlot<T>>;
128
129type TransactWriteTokenCache =
131 TokenCache<actions::transact_write_items::TransactWriteItemsResponse>;
132
133type ExecuteTransactionTokenCache =
139 TokenCache<actions::execute_transaction::ExecuteTransactionResponse>;
140
141#[derive(Default)]
146pub struct TokenCaches {
147 #[cfg_attr(
151 not(any(feature = "native-sqlite", feature = "_has-encryption")),
152 allow(dead_code)
153 )]
154 transact_write: Mutex<TransactWriteTokenCache>,
155 execute_transaction: Mutex<ExecuteTransactionTokenCache>,
156}
157
158impl TokenCaches {
159 pub fn new() -> Self {
161 Self::default()
162 }
163
164 #[cfg(any(feature = "wasm-sqlite", test))]
165 pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
166 &self.execute_transaction
167 }
168}
169
170const MAX_TOKEN_LEN: usize = 36;
172
173const TOKEN_EXPIRY_SECS: u64 = 600;
176
177fn validate_token(token: Option<&str>) -> Result<()> {
179 match token {
180 Some(token) if token.len() > MAX_TOKEN_LEN => {
181 Err(DynoxideError::ValidationException(format!(
182 "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
183 )))
184 }
185 _ => Ok(()),
186 }
187}
188
189fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
196 use std::hash::{Hash, Hasher};
197 let normalised = serde_json::to_value(input)
198 .and_then(|v| serde_json::to_vec(&v))
199 .unwrap_or_default();
200 let mut hasher = std::collections::hash_map::DefaultHasher::new();
201 normalised.hash(&mut hasher);
202 hasher.finish()
203}
204
205fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
206 cache
207 .lock()
208 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
209}
210
211fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
217 cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
218}
219
220fn token_window() -> Duration {
222 Duration::from_secs(TOKEN_EXPIRY_SECS)
223}
224
225#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
243fn run_idempotent<T, H, E, R>(
244 cache: &Mutex<TokenCache<T>>,
245 token: Option<&str>,
246 hash_input: &H,
247 execute: E,
248 replay: R,
249) -> Result<T>
250where
251 T: Clone,
252 H: serde::Serialize,
253 E: FnOnce() -> Result<T>,
254 R: FnOnce(&T) -> T,
255{
256 validate_token(token)?;
257
258 let Some(token) = token else {
260 return execute();
261 };
262 let hash = request_hash(hash_input);
263
264 let mut cache = lock_cache(cache)?;
265 evict_expired(&mut cache, token_window());
266 let cached = match cache.get(token) {
267 Some((_, cached_hash, _)) if *cached_hash != hash => {
268 return Err(DynoxideError::IdempotentParameterMismatchException(
269 "An error occurred (IdempotentParameterMismatchException)".to_string(),
270 ));
271 }
272 Some((_, _, slot)) => slot.clone(),
276 None => None,
277 };
278 if let Some(cached) = cached {
279 drop(cache);
281 return Ok(replay(&cached));
282 }
283 let resp = execute()?;
287 cache.insert(
288 token.to_string(),
289 (Instant::now(), hash, Some(resp.clone())),
290 );
291 Ok(resp)
292}
293
294#[cfg(any(feature = "wasm-sqlite", test))]
297const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
298
299#[cfg(any(feature = "wasm-sqlite", test))]
301enum TokenClaim<T> {
302 Marked(Instant),
306 InFlight,
308 Mismatch,
310 Hit(T),
312}
313
314#[cfg(any(feature = "wasm-sqlite", test))]
320fn lookup_or_claim<T: Clone>(
321 cache: &Mutex<TokenCache<T>>,
322 token: &str,
323 hash: u64,
324) -> Result<TokenClaim<T>> {
325 lookup_or_claim_within(cache, token, hash, token_window())
326}
327
328#[cfg(any(feature = "wasm-sqlite", test))]
331fn lookup_or_claim_within<T: Clone>(
332 cache: &Mutex<TokenCache<T>>,
333 token: &str,
334 hash: u64,
335 window: Duration,
336) -> Result<TokenClaim<T>> {
337 let mut cache = lock_cache(cache)?;
338 evict_expired(&mut cache, window);
339 Ok(match cache.get(token) {
340 Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
341 Some((_, _, None)) => TokenClaim::InFlight,
342 Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
343 None => {
344 let claimed_at = Instant::now();
345 cache.insert(token.to_string(), (claimed_at, hash, None));
346 TokenClaim::Marked(claimed_at)
347 }
348 })
349}
350
351#[cfg(any(feature = "wasm-sqlite", test))]
358fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
359 matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
360}
361
362#[cfg(any(feature = "wasm-sqlite", test))]
364fn record_complete<T>(
365 cache: &Mutex<TokenCache<T>>,
366 token: &str,
367 claimed_at: Instant,
368 hash: u64,
369 resp: T,
370) -> Result<()> {
371 let mut cache = lock_cache(cache)?;
372 if still_ours(&cache, token, claimed_at) {
373 cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
374 }
375 Ok(())
376}
377
378#[cfg(any(feature = "wasm-sqlite", test))]
381fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
382 let mut cache = lock_cache(cache)?;
383 if still_ours(&cache, token, claimed_at) {
384 cache.remove(token);
385 }
386 Ok(())
387}
388
389#[cfg(any(feature = "wasm-sqlite", test))]
402async fn run_idempotent_async<T, H, F, R>(
403 cache: &Mutex<TokenCache<T>>,
404 token: Option<&str>,
405 hash_input: &H,
406 execute: F,
407 replay: R,
408) -> Result<T>
409where
410 T: Clone,
411 H: serde::Serialize,
412 F: std::future::Future<Output = Result<T>>,
413 R: FnOnce(&T) -> T,
414{
415 validate_token(token)?;
416
417 let Some(token) = token else {
418 return execute.await;
419 };
420 let hash = request_hash(hash_input);
421
422 let claimed_at = match lookup_or_claim(cache, token, hash)? {
423 TokenClaim::Hit(cached) => return Ok(replay(&cached)),
424 TokenClaim::Mismatch => {
425 return Err(DynoxideError::IdempotentParameterMismatchException(
426 "An error occurred (IdempotentParameterMismatchException)".to_string(),
427 ));
428 }
429 TokenClaim::InFlight => {
430 return Err(DynoxideError::InternalServerError(
431 IN_FLIGHT_MESSAGE.to_string(),
432 ));
433 }
434 TokenClaim::Marked(claimed_at) => claimed_at,
435 };
436
437 match execute.await {
438 Ok(resp) => {
439 let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
443 Ok(resp)
444 }
445 Err(e) => {
446 let _ = clear_claim(cache, token, claimed_at);
447 Err(e)
448 }
449 }
450}
451
452#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
457pub type RusqliteBackend = storage::Storage;
458
459#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
467pub type NativeDatabase = Database<RusqliteBackend>;
468
469#[cfg(feature = "wasm-sqlite")]
476pub type WasmDatabase = Database<WasmBridgeBackend>;
477
478#[cfg(feature = "wasm-sqlite")]
486pub const WASM_PREVIEW: bool = true;
487#[cfg(not(feature = "wasm-sqlite"))]
490pub const WASM_PREVIEW: bool = false;
491
492#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
501pub struct Database<S = RusqliteBackend> {
502 inner: Arc<Mutex<S>>,
503 tokens: Arc<TokenCaches>,
504}
505
506#[cfg(all(
512 not(any(feature = "native-sqlite", feature = "_has-encryption")),
513 feature = "wasm-sqlite"
514))]
515use async_lock::Mutex as BackendMutex;
516#[cfg(all(
517 not(any(feature = "native-sqlite", feature = "_has-encryption")),
518 not(feature = "wasm-sqlite")
519))]
520use std::sync::Mutex as BackendMutex;
521
522#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
532pub struct Database<S> {
533 inner: Arc<BackendMutex<S>>,
534 tokens: Arc<TokenCaches>,
535}
536
537impl<S> Clone for Database<S> {
539 fn clone(&self) -> Self {
540 Self {
541 inner: Arc::clone(&self.inner),
542 tokens: Arc::clone(&self.tokens),
543 }
544 }
545}
546
547#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
548impl Database<RusqliteBackend> {
549 pub fn new(path: &str) -> Result<Self> {
551 let storage = storage::Storage::new(path)?;
552 Ok(Self {
553 inner: Arc::new(Mutex::new(storage)),
554 tokens: Arc::new(TokenCaches::new()),
555 })
556 }
557
558 #[cfg(feature = "_has-encryption")]
579 pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
580 if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
581 return Err(DynoxideError::ValidationException(
582 "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
583 ));
584 }
585
586 let storage = storage::Storage::new_encrypted(path, key)?;
587 Ok(Self {
588 inner: Arc::new(Mutex::new(storage)),
589 tokens: Arc::new(TokenCaches::new()),
590 })
591 }
592
593 pub fn memory() -> Result<Self> {
595 let storage = storage::Storage::memory()?;
596 Ok(Self {
597 inner: Arc::new(Mutex::new(storage)),
598 tokens: Arc::new(TokenCaches::new()),
599 })
600 }
601
602 pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
604 where
605 F: FnOnce(&storage::Storage) -> Result<T>,
606 {
607 let guard = self
608 .inner
609 .lock()
610 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
611 f(&guard)
612 }
613
614 pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
616 where
617 F: FnOnce(&mut storage::Storage) -> Result<T>,
618 {
619 let mut guard = self
620 .inner
621 .lock()
622 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
623 f(&mut guard)
624 }
625
626 pub fn create_table(
632 &self,
633 request: actions::create_table::CreateTableRequest,
634 ) -> Result<actions::create_table::CreateTableResponse> {
635 self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
636 }
637
638 pub fn delete_table(
640 &self,
641 request: actions::delete_table::DeleteTableRequest,
642 ) -> Result<actions::delete_table::DeleteTableResponse> {
643 self.with_storage(|s| pollster::block_on(actions::delete_table::execute(s, request)))
644 }
645
646 pub fn describe_table(
648 &self,
649 request: actions::describe_table::DescribeTableRequest,
650 ) -> Result<actions::describe_table::DescribeTableResponse> {
651 self.with_storage(|s| pollster::block_on(actions::describe_table::execute(s, request)))
652 }
653
654 pub fn update_table(
656 &self,
657 request: actions::update_table::UpdateTableRequest,
658 ) -> Result<actions::update_table::UpdateTableResponse> {
659 self.with_storage(|s| pollster::block_on(actions::update_table::execute(s, request)))
660 }
661
662 pub fn list_tables(
664 &self,
665 request: actions::list_tables::ListTablesRequest,
666 ) -> Result<actions::list_tables::ListTablesResponse> {
667 self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
668 }
669
670 pub fn tag_resource(
676 &self,
677 request: actions::tag_resource::TagResourceRequest,
678 ) -> Result<actions::tag_resource::TagResourceResponse> {
679 self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
680 }
681
682 pub fn untag_resource(
684 &self,
685 request: actions::untag_resource::UntagResourceRequest,
686 ) -> Result<actions::untag_resource::UntagResourceResponse> {
687 self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
688 }
689
690 pub fn list_tags_of_resource(
692 &self,
693 request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
694 ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
695 self.with_storage(|s| {
696 pollster::block_on(actions::list_tags_of_resource::execute(s, request))
697 })
698 }
699
700 pub fn put_item(
706 &self,
707 request: actions::put_item::PutItemRequest,
708 ) -> Result<actions::put_item::PutItemResponse> {
709 self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
710 }
711
712 pub fn get_item(
714 &self,
715 request: actions::get_item::GetItemRequest,
716 ) -> Result<actions::get_item::GetItemResponse> {
717 self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
718 }
719
720 pub fn delete_item(
722 &self,
723 request: actions::delete_item::DeleteItemRequest,
724 ) -> Result<actions::delete_item::DeleteItemResponse> {
725 self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
726 }
727
728 pub fn update_item(
730 &self,
731 request: actions::update_item::UpdateItemRequest,
732 ) -> Result<actions::update_item::UpdateItemResponse> {
733 self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
734 }
735
736 pub fn batch_get_item(
742 &self,
743 request: actions::batch_get_item::BatchGetItemRequest,
744 ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
745 self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
746 }
747
748 pub fn batch_write_item(
750 &self,
751 request: actions::batch_write_item::BatchWriteItemRequest,
752 ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
753 self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
754 }
755
756 pub fn import_items(
771 &self,
772 table_name: &str,
773 items: Vec<Item>,
774 options: ImportOptions,
775 ) -> Result<ImportResult> {
776 self.with_storage(|s| {
777 pollster::block_on(actions::import_items::execute(
778 s, table_name, items, &options,
779 ))
780 })
781 }
782
783 #[cfg(feature = "import")]
789 pub(crate) fn import_items_fresh(
790 &self,
791 table_name: &str,
792 items: Vec<Item>,
793 options: ImportOptions,
794 ) -> Result<ImportResult> {
795 self.with_storage(|s| {
796 pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
797 s, table_name, items, &options,
798 ))
799 })
800 }
801
802 pub fn enable_bulk_loading(&self) -> Result<()> {
811 self.with_storage(|s| s.enable_bulk_loading())
812 }
813
814 pub fn disable_bulk_loading(&self) -> Result<()> {
816 self.with_storage(|s| s.disable_bulk_loading())
817 }
818
819 pub fn query(
825 &self,
826 request: actions::query::QueryRequest,
827 ) -> Result<actions::query::QueryResponse> {
828 self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
829 }
830
831 pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
833 self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
834 }
835
836 pub fn transact_write_items(
847 &self,
848 request: actions::transact_write_items::TransactWriteItemsRequest,
849 ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
850 run_idempotent(
851 &self.tokens.transact_write,
852 request.client_request_token.as_deref(),
853 &request.transact_items,
854 || {
855 self.with_storage(|s| {
856 pollster::block_on(actions::transact_write_items::execute(s, request.clone()))
857 })
858 },
859 |cached| {
860 actions::transact_write_items::replay_response(
865 &request.transact_items,
866 &request.return_consumed_capacity,
867 cached.item_collection_metrics.clone(),
868 )
869 },
870 )
871 }
872
873 pub fn transact_get_items(
875 &self,
876 request: actions::transact_get_items::TransactGetItemsRequest,
877 ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
878 self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
879 }
880
881 pub fn list_streams(
887 &self,
888 request: actions::list_streams::ListStreamsRequest,
889 ) -> Result<actions::list_streams::ListStreamsResponse> {
890 self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
891 }
892
893 pub fn describe_stream(
895 &self,
896 request: actions::describe_stream::DescribeStreamRequest,
897 ) -> Result<actions::describe_stream::DescribeStreamResponse> {
898 self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
899 }
900
901 pub fn get_shard_iterator(
903 &self,
904 request: actions::get_shard_iterator::GetShardIteratorRequest,
905 ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
906 self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
907 }
908
909 pub fn get_records(
911 &self,
912 request: actions::get_records::GetRecordsRequest,
913 ) -> Result<actions::get_records::GetRecordsResponse> {
914 self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
915 }
916
917 pub fn update_time_to_live(
923 &self,
924 request: actions::update_time_to_live::UpdateTimeToLiveRequest,
925 ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
926 self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
927 }
928
929 pub fn describe_time_to_live(
931 &self,
932 request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
933 ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
934 self.with_storage(|s| {
935 pollster::block_on(actions::describe_time_to_live::execute(s, request))
936 })
937 }
938
939 pub fn sweep_ttl(&self) -> Result<usize> {
942 self.with_storage(|s| pollster::block_on(ttl::sweep_expired_items(s)))
943 }
944
945 pub fn execute_statement(
951 &self,
952 request: actions::execute_statement::ExecuteStatementRequest,
953 ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
954 self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
955 }
956
957 pub fn execute_transaction(
965 &self,
966 request: actions::execute_transaction::ExecuteTransactionRequest,
967 ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
968 run_idempotent(
969 &self.tokens.execute_transaction,
970 request.client_request_token.as_deref(),
971 &request.transact_statements,
972 || {
973 self.with_storage(|s| {
974 pollster::block_on(actions::execute_transaction::execute(s, request.clone()))
975 })
976 },
977 |cached| {
978 actions::execute_transaction::replay_response(
981 &request.transact_statements,
982 &request.return_consumed_capacity,
983 cached.responses.clone(),
984 )
985 },
986 )
987 }
988
989 pub fn batch_execute_statement(
991 &self,
992 request: actions::batch_execute_statement::BatchExecuteStatementRequest,
993 ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
994 self.with_storage(|s| {
995 pollster::block_on(actions::batch_execute_statement::execute(s, request))
996 })
997 }
998
999 pub fn touch_cached_at(
1008 &self,
1009 table_name: &str,
1010 pk: &str,
1011 sk: &str,
1012 timestamp: f64,
1013 ) -> Result<()> {
1014 self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
1015 }
1016
1017 pub fn get_lru_items(
1022 &self,
1023 table_name: &str,
1024 limit: usize,
1025 ) -> Result<Vec<(String, String, i64)>> {
1026 self.with_storage(|s| s.get_lru_items(table_name, limit))
1027 }
1028
1029 pub fn db_path(&self) -> Result<Option<String>> {
1035 self.with_storage(|s| Ok(s.db_path()))
1036 }
1037
1038 pub fn db_size_bytes(&self) -> Result<u64> {
1040 self.with_storage(|s| s.db_size_bytes())
1041 }
1042
1043 pub fn table_count(&self) -> Result<usize> {
1045 self.with_storage(|s| s.table_count())
1046 }
1047
1048 pub fn table_stats(&self) -> Result<Vec<TableStats>> {
1050 self.with_storage(|s| s.table_stats())
1051 }
1052
1053 pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
1055 self.with_storage(|s| s.get_table_metadata(table_name))
1056 }
1057
1058 pub fn database_info(&self) -> Result<DatabaseInfo> {
1063 self.with_storage(|s| s.database_info())
1064 }
1065
1066 pub fn vacuum(&self) -> Result<()> {
1072 self.with_storage(|s| s.vacuum())
1073 }
1074
1075 pub fn vacuum_into(&self, path: &str) -> Result<()> {
1080 self.with_storage(|s| s.vacuum_into(path))
1081 }
1082
1083 pub fn restore_from(&self, path: &str) -> Result<()> {
1089 self.with_storage_mut(|s| s.restore_from(path))
1090 }
1091
1092 #[cfg(feature = "mcp-server")]
1097 pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
1098 self.with_storage(|s| s.backup_to_memory())
1099 }
1100
1101 #[cfg(feature = "mcp-server")]
1105 pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
1106 self.with_storage_mut(|s| s.restore_from_connection(source))
1107 }
1108}
1109
1110#[cfg(feature = "wasm-sqlite")]
1124impl Database<WasmBridgeBackend> {
1125 pub async fn open(name: &str) -> Result<Self> {
1128 Self::open_with(name, false).await
1129 }
1130
1131 pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
1134 let backend = WasmBridgeBackend::open_with(name, ephemeral)
1135 .await
1136 .map_err(DynoxideError::from)?;
1137 Ok(Self {
1138 inner: Arc::new(BackendMutex::new(backend)),
1139 tokens: Arc::new(TokenCaches::new()),
1140 })
1141 }
1142
1143 pub async fn persistence_mode(&self) -> String {
1145 self.backend().await.persistence_mode().to_string()
1146 }
1147
1148 pub async fn close(&self) -> Result<()> {
1152 self.backend()
1153 .await
1154 .close()
1155 .await
1156 .map_err(DynoxideError::from)
1157 }
1158
1159 pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
1168 self.inner.lock().await
1169 }
1170
1171 pub(crate) fn token_caches(&self) -> &TokenCaches {
1175 &self.tokens
1176 }
1177
1178 pub async fn create_table(
1180 &self,
1181 request: actions::create_table::CreateTableRequest,
1182 ) -> Result<actions::create_table::CreateTableResponse> {
1183 let backend = self.backend().await;
1184 actions::create_table::execute(&*backend, request).await
1185 }
1186
1187 pub async fn delete_table(
1189 &self,
1190 request: actions::delete_table::DeleteTableRequest,
1191 ) -> Result<actions::delete_table::DeleteTableResponse> {
1192 let backend = self.backend().await;
1193 actions::delete_table::execute(&*backend, request).await
1194 }
1195
1196 pub async fn describe_table(
1198 &self,
1199 request: actions::describe_table::DescribeTableRequest,
1200 ) -> Result<actions::describe_table::DescribeTableResponse> {
1201 let backend = self.backend().await;
1202 actions::describe_table::execute(&*backend, request).await
1203 }
1204
1205 pub async fn list_tables(
1207 &self,
1208 request: actions::list_tables::ListTablesRequest,
1209 ) -> Result<actions::list_tables::ListTablesResponse> {
1210 let backend = self.backend().await;
1211 actions::list_tables::execute(&*backend, request).await
1212 }
1213
1214 pub async fn put_item(
1216 &self,
1217 request: actions::put_item::PutItemRequest,
1218 ) -> Result<actions::put_item::PutItemResponse> {
1219 let backend = self.backend().await;
1220 actions::put_item::execute(&*backend, request).await
1221 }
1222
1223 pub async fn get_item(
1225 &self,
1226 request: actions::get_item::GetItemRequest,
1227 ) -> Result<actions::get_item::GetItemResponse> {
1228 let backend = self.backend().await;
1229 actions::get_item::execute(&*backend, request).await
1230 }
1231
1232 pub async fn delete_item(
1234 &self,
1235 request: actions::delete_item::DeleteItemRequest,
1236 ) -> Result<actions::delete_item::DeleteItemResponse> {
1237 let backend = self.backend().await;
1238 actions::delete_item::execute(&*backend, request).await
1239 }
1240
1241 pub async fn query(
1243 &self,
1244 request: actions::query::QueryRequest,
1245 ) -> Result<actions::query::QueryResponse> {
1246 let backend = self.backend().await;
1247 actions::query::execute(&*backend, request).await
1248 }
1249
1250 pub async fn scan(
1252 &self,
1253 request: actions::scan::ScanRequest,
1254 ) -> Result<actions::scan::ScanResponse> {
1255 let backend = self.backend().await;
1256 actions::scan::execute(&*backend, request).await
1257 }
1258}
1259
1260#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1261mod tests {
1262 use super::*;
1263
1264 #[test]
1265 fn test_database_memory() {
1266 let db = Database::memory().unwrap();
1267 let _db2 = db.clone();
1269 }
1270
1271 #[test]
1272 fn test_database_with_storage() {
1273 let db = Database::memory().unwrap();
1274 let tables = db.with_storage(|s| s.list_table_names()).unwrap();
1275 assert!(tables.is_empty());
1276 }
1277
1278 #[test]
1279 fn test_database_thread_safe() {
1280 let db = Database::memory().unwrap();
1281 let db2 = db.clone();
1282
1283 let handle =
1284 std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
1285
1286 let tables = handle.join().unwrap();
1287 assert!(tables.is_empty());
1288 }
1289
1290 #[test]
1291 fn test_native_database_alias_round_trips() {
1292 let db: NativeDatabase = Database::memory().unwrap();
1296
1297 db.create_table(actions::create_table::CreateTableRequest {
1298 table_name: "tbl".to_string(),
1299 key_schema: vec![types::KeySchemaElement {
1300 attribute_name: "pk".to_string(),
1301 key_type: types::KeyType::HASH,
1302 }],
1303 attribute_definitions: vec![types::AttributeDefinition {
1304 attribute_name: "pk".to_string(),
1305 attribute_type: types::ScalarAttributeType::S,
1306 }],
1307 ..Default::default()
1308 })
1309 .unwrap();
1310
1311 let mut item = HashMap::new();
1312 item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1313 db.put_item(actions::put_item::PutItemRequest {
1314 table_name: "tbl".to_string(),
1315 item,
1316 ..Default::default()
1317 })
1318 .unwrap();
1319
1320 let mut key = HashMap::new();
1321 key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1322 let got = db
1323 .get_item(actions::get_item::GetItemRequest {
1324 table_name: "tbl".to_string(),
1325 key,
1326 ..Default::default()
1327 })
1328 .unwrap();
1329 assert_eq!(
1330 got.item.unwrap().get("pk"),
1331 Some(&AttributeValue::S("a".to_string()))
1332 );
1333 }
1334}
1335
1336#[cfg(test)]
1342mod idempotency_tests {
1343 use super::*;
1344 use std::cell::Cell;
1345
1346 const KEY: &str = "statements";
1347 const TOKEN: &str = "tok";
1348
1349 fn cache() -> Mutex<TokenCache<u32>> {
1350 Mutex::new(HashMap::new())
1351 }
1352
1353 fn hash() -> u64 {
1354 request_hash(&KEY)
1355 }
1356
1357 fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
1358 where
1359 F: std::future::Future<Output = Result<u32>>,
1360 {
1361 pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
1362 }
1363
1364 #[test]
1365 fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
1366 let cache = cache();
1367
1368 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1369 panic!("the first caller should have claimed the token");
1370 };
1371 assert!(matches!(
1372 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1373 TokenClaim::InFlight
1374 ));
1375
1376 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1377 assert!(matches!(
1378 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1379 TokenClaim::Hit(7)
1380 ));
1381 }
1382
1383 #[test]
1384 fn only_one_caller_can_claim_a_token() {
1385 use std::sync::Barrier;
1389
1390 const N: usize = 16;
1391 let cache = cache();
1392 let barrier = Barrier::new(N);
1393
1394 let marked = std::thread::scope(|scope| {
1395 let handles: Vec<_> = (0..N)
1396 .map(|_| {
1397 scope.spawn(|| {
1398 barrier.wait();
1399 matches!(
1400 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1401 TokenClaim::Marked(_)
1402 )
1403 })
1404 })
1405 .collect();
1406 handles
1407 .into_iter()
1408 .map(|h| h.join().unwrap())
1409 .filter(|claimed| *claimed)
1410 .count()
1411 });
1412
1413 assert_eq!(marked, 1);
1414 }
1415
1416 #[test]
1417 fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
1418 let cache = cache();
1422 let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1423 panic!("the first caller should have claimed the token");
1424 };
1425 let TokenClaim::Marked(second) =
1426 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
1427 else {
1428 panic!("the expired claim should have been re-issued");
1429 };
1430 assert_ne!(first, second);
1431
1432 record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
1434 assert!(matches!(
1435 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1436 TokenClaim::InFlight
1437 ));
1438
1439 clear_claim(&cache, TOKEN, first).unwrap();
1441 assert!(matches!(
1442 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1443 TokenClaim::InFlight
1444 ));
1445
1446 record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
1448 assert!(matches!(
1449 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1450 TokenClaim::Hit(2)
1451 ));
1452 }
1453
1454 #[test]
1455 fn a_different_request_under_the_same_token_mismatches_in_both_states() {
1456 let other = request_hash(&"different");
1457
1458 let cache = cache();
1459 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1460 panic!("expected a free slot to claim");
1461 };
1462 assert!(matches!(
1463 lookup_or_claim(&cache, TOKEN, other).unwrap(),
1464 TokenClaim::Mismatch
1465 ));
1466
1467 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1468 assert!(matches!(
1469 lookup_or_claim(&cache, TOKEN, other).unwrap(),
1470 TokenClaim::Mismatch
1471 ));
1472 }
1473
1474 #[test]
1475 fn the_claim_lands_before_the_call_is_polled() {
1476 let cache = cache();
1478 let claimed_first = Cell::new(false);
1479
1480 let out = drive(&cache, Some(TOKEN), async {
1481 let slots = cache
1484 .try_lock()
1485 .expect("the cache lock must be released before the call runs");
1486 claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
1487 Ok(7)
1488 });
1489
1490 assert_eq!(out.unwrap(), 7);
1491 assert!(claimed_first.get());
1492 }
1493
1494 #[test]
1495 fn a_caller_arriving_under_a_live_claim_does_not_execute() {
1496 let cache = cache();
1497 let second_ran = Cell::new(false);
1498
1499 let out = drive(&cache, Some(TOKEN), async {
1500 let second = drive(&cache, Some(TOKEN), async {
1501 second_ran.set(true);
1502 Ok(0)
1503 });
1504 assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
1507 assert!(matches!(
1510 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1511 TokenClaim::InFlight
1512 ));
1513 Ok(1)
1514 });
1515
1516 assert_eq!(out.unwrap(), 1);
1517 assert!(!second_ran.get(), "the second call must not run the work");
1518 assert!(matches!(
1520 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1521 TokenClaim::Hit(1)
1522 ));
1523 }
1524
1525 #[test]
1526 fn the_synchronous_driver_treats_a_live_claim_as_absent() {
1527 let cache = cache();
1532 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
1533
1534 let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
1535 assert_eq!(
1536 out, 9,
1537 "a claimed slot is not treated as a replayable result"
1538 );
1539 }
1540
1541 #[test]
1542 fn a_settled_token_replays_without_re_executing() {
1543 let cache = cache();
1544 let runs = Cell::new(0);
1545
1546 for _ in 0..2 {
1547 let out = drive(&cache, Some(TOKEN), async {
1548 runs.set(runs.get() + 1);
1549 Ok(7)
1550 });
1551 assert_eq!(out.unwrap(), 7);
1552 }
1553 assert_eq!(runs.get(), 1);
1554 }
1555
1556 #[test]
1557 fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
1558 let cache = cache();
1559 let runs = Cell::new(0);
1560
1561 let first = drive(&cache, Some(TOKEN), async {
1562 runs.set(runs.get() + 1);
1563 Err(DynoxideError::ValidationException("no".into()))
1564 });
1565 assert!(first.is_err());
1566 assert!(
1567 cache.lock().unwrap().is_empty(),
1568 "the claim must be released"
1569 );
1570
1571 let second = drive(&cache, Some(TOKEN), async {
1572 runs.set(runs.get() + 1);
1573 Ok(7)
1574 });
1575 assert_eq!(second.unwrap(), 7);
1576 assert_eq!(runs.get(), 2);
1577 }
1578
1579 #[test]
1580 fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
1581 let cache = cache();
1584 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
1585
1586 assert!(matches!(
1587 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
1588 TokenClaim::Marked(_)
1589 ));
1590 }
1591
1592 #[test]
1593 fn a_settled_token_stops_replaying_once_it_expires() {
1594 let cache = cache();
1595 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1596 panic!("expected a free slot to claim");
1597 };
1598 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1599
1600 assert!(matches!(
1602 lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
1603 TokenClaim::Hit(7)
1604 ));
1605 assert!(matches!(
1607 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
1608 TokenClaim::Marked(_)
1609 ));
1610 }
1611
1612 #[test]
1613 fn concurrent_callers_under_one_token_execute_the_work_once() {
1614 use std::sync::Barrier;
1618 use std::sync::atomic::{AtomicUsize, Ordering};
1619
1620 const N: usize = 16;
1621 let cache = cache();
1622 let runs = AtomicUsize::new(0);
1623 let barrier = Barrier::new(N);
1624
1625 let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
1626 let handles: Vec<_> = (0..N)
1627 .map(|_| {
1628 scope.spawn(|| {
1629 barrier.wait();
1630 drive(&cache, Some(TOKEN), async {
1631 runs.fetch_add(1, Ordering::SeqCst);
1632 Ok(7)
1633 })
1634 })
1635 })
1636 .collect();
1637 handles.into_iter().map(|h| h.join().unwrap()).collect()
1638 });
1639
1640 assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
1641 for outcome in outcomes {
1644 match outcome {
1645 Ok(v) => assert_eq!(v, 7),
1646 Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
1647 }
1648 }
1649 }
1650
1651 #[test]
1652 fn an_overlong_token_is_rejected_with_dynamodbs_message() {
1653 let token = "x".repeat(MAX_TOKEN_LEN + 1);
1654 let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
1655 assert_eq!(
1656 err.to_string(),
1657 format!(
1658 "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
1659 )
1660 );
1661 }
1662
1663 #[test]
1664 fn a_tokenless_call_never_touches_the_cache() {
1665 let cache = cache();
1666 let runs = Cell::new(0);
1667
1668 for _ in 0..2 {
1669 drive(&cache, None, async {
1670 runs.set(runs.get() + 1);
1671 Ok(7)
1672 })
1673 .unwrap();
1674 }
1675 assert_eq!(runs.get(), 2);
1676 assert!(cache.lock().unwrap().is_empty());
1677 }
1678}