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(all(
33 feature = "wasm-sqlite",
34 any(
35 feature = "native-sqlite",
36 feature = "_has-encryption",
37 feature = "cli"
38 )
39))]
40compile_error!(
41 "The `wasm-sqlite` backend is mutually exclusive with the native backends and \
42 with the CLI, server and MCP features, which are native-only.\n\
43 If your manifest names none of those, they came from the defaults: Cargo adds \
44 the default features to whatever you list, so `features = [\"wasm-sqlite\"]` \
45 enables the native backend as well. Turn them off:\n \
46 dynoxide-rs = { version = \"...\", default-features = false, features = [\"wasm-sqlite\"] }"
47);
48
49#[cfg(not(any(
50 feature = "native-sqlite",
51 feature = "_has-encryption",
52 feature = "wasm-sqlite"
53)))]
54compile_error!(
55 "A storage backend feature must be enabled: `native-sqlite`, `encryption`, \
56 `encryption-cc`, or `wasm-sqlite`. Default features include `native-sqlite`. \
57 If you used `default-features = false`, add one of these features."
58);
59
60pub mod actions;
61pub mod auth_material;
62pub mod bench_counters;
63pub(crate) mod empty_names;
65pub mod errors;
66pub(crate) mod expressions;
67#[cfg(feature = "import")]
68pub mod import;
69#[doc(hidden)]
70pub mod macros;
71#[cfg(feature = "mcp-server")]
72pub mod mcp;
73#[cfg(any(feature = "http-server", feature = "mcp-server"))]
74pub(crate) mod net;
75pub(crate) mod partiql;
76pub mod schema;
77pub(crate) mod set_order;
79pub(crate) mod serde_errors;
83#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
87pub(crate) mod serialization_checks;
88pub(crate) mod value_nesting;
91#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
94pub(crate) mod deep_nesting;
95#[cfg(feature = "http-server")]
96pub mod server;
97#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
100pub(crate) mod handoff_mutex;
101
102#[cfg(any(feature = "http-server", feature = "mcp-server"))]
105mod server_options;
106#[cfg(feature = "mcp-server")]
107pub(crate) mod snapshots;
108pub mod storage;
109pub mod storage_backend;
110pub mod streams;
111pub mod ttl;
112pub mod types;
113pub mod validation;
114#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
118pub(crate) mod dynamo_ops;
119#[cfg(any(feature = "wasm-sqlite", test))]
124pub mod wasm_api;
125#[cfg(feature = "wasm-harness")]
126pub mod wasm_harness;
127
128#[doc(hidden)]
129pub use macros::ItemInsert;
130
131use std::collections::HashMap;
132use std::sync::{Arc, Mutex};
133use web_time::{Duration, Instant};
134
135pub use errors::{DynoxideError, Result};
136pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
137pub use storage_backend::BackendError;
138#[cfg(feature = "wasm-sqlite")]
139pub use storage_backend::WasmBridgeBackend;
140
141pub use types::{AttributeValue, ConversionError, Item};
142
143#[cfg(any(feature = "http-server", feature = "mcp-server"))]
144pub use server_options::ServerOptions;
145
146pub const PRODUCT_VERSION: &str = env!("DYNOXIDE_PRODUCT_VERSION");
154
155use actions::vector_lifecycle::VectorIndexLifecycle;
156
157#[derive(Debug, Clone, Default)]
159pub struct ImportOptions {
160 pub record_streams: bool,
162 pub set_cached_at: bool,
164}
165
166#[derive(Debug, Clone)]
168pub struct ImportResult {
169 pub items_imported: usize,
171 pub bytes_imported: usize,
173}
174
175type TokenSlot<T> = (Instant, u64, Option<T>);
183
184type TokenCache<T> = HashMap<String, TokenSlot<T>>;
186
187type TransactWriteTokenCache = TokenCache<actions::transact_write_items::CachedWrite>;
191
192type ExecuteTransactionTokenCache = TokenCache<actions::execute_transaction::CachedTransaction>;
198
199#[derive(Default)]
204pub struct TokenCaches {
205 #[cfg_attr(
209 not(any(feature = "native-sqlite", feature = "_has-encryption")),
210 allow(dead_code)
211 )]
212 transact_write: Mutex<TransactWriteTokenCache>,
213 execute_transaction: Mutex<ExecuteTransactionTokenCache>,
214}
215
216impl TokenCaches {
217 pub fn new() -> Self {
219 Self::default()
220 }
221
222 #[cfg(any(feature = "wasm-sqlite", test))]
223 pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
224 &self.execute_transaction
225 }
226
227 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
236 pub(crate) fn lock_all(&self) -> TokenCacheGuards<'_> {
237 TokenCacheGuards {
238 transact_write: self
239 .transact_write
240 .lock()
241 .unwrap_or_else(|e| e.into_inner()),
242 execute_transaction: self
243 .execute_transaction
244 .lock()
245 .unwrap_or_else(|e| e.into_inner()),
246 }
247 }
248}
249
250#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
252pub(crate) struct TokenCacheGuards<'a> {
253 transact_write: std::sync::MutexGuard<'a, TransactWriteTokenCache>,
254 execute_transaction: std::sync::MutexGuard<'a, ExecuteTransactionTokenCache>,
255}
256
257#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
258impl TokenCacheGuards<'_> {
259 pub(crate) fn clear(&mut self) {
261 self.transact_write.clear();
262 self.execute_transaction.clear();
263 }
264}
265
266const MAX_TOKEN_LEN: usize = 36;
268
269const TOKEN_EXPIRY_SECS: u64 = 600;
272
273fn validate_token(token: Option<&str>) -> Result<()> {
275 match token_constraint_error(token) {
276 Some(msg) => Err(DynoxideError::ValidationException(
277 validation::envelope_message(&msg),
278 )),
279 None => Ok(()),
280 }
281}
282
283pub(crate) fn token_constraint_error(token: Option<&str>) -> Option<String> {
287 match token {
288 Some(token) if token.len() > MAX_TOKEN_LEN => Some(format!(
289 "Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
290 )),
291 _ => None,
292 }
293}
294
295fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
302 use std::hash::{Hash, Hasher};
303 let mut hasher = std::collections::hash_map::DefaultHasher::new();
304 canonical_json(input).hash(&mut hasher);
305 hasher.finish()
306}
307
308pub(crate) fn canonical_json<T: serde::Serialize + ?Sized>(value: &T) -> Vec<u8> {
317 serde_json::to_value(value)
318 .map(sorted_json)
319 .and_then(|v| serde_json::to_vec(&v))
320 .unwrap_or_default()
321}
322
323pub(crate) fn sorted_json(value: serde_json::Value) -> serde_json::Value {
327 match value {
328 serde_json::Value::Object(map) => {
329 let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
330 entries.sort_by(|a, b| a.0.cmp(&b.0));
331 serde_json::Value::Object(
332 entries
333 .into_iter()
334 .map(|(k, v)| (k, sorted_json(v)))
335 .collect(),
336 )
337 }
338 serde_json::Value::Array(items) => {
339 serde_json::Value::Array(items.into_iter().map(sorted_json).collect())
340 }
341 other => other,
342 }
343}
344
345fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
346 cache
347 .lock()
348 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
349}
350
351fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
357 cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
358}
359
360fn token_window() -> Duration {
362 Duration::from_secs(TOKEN_EXPIRY_SECS)
363}
364
365#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
386fn run_idempotent<T, H, E, R>(
387 cache: &Mutex<TokenCache<T>>,
388 token: Option<&str>,
389 hash_input: &H,
390 execute: E,
391 replay: R,
392) -> Result<T>
393where
394 T: Clone,
395 H: serde::Serialize,
396 E: FnOnce() -> Result<T>,
397 R: FnOnce(&T) -> T,
398{
399 validate_token(token)?;
400
401 let Some(token) = token else {
403 return execute();
404 };
405 let hash = request_hash(hash_input);
406
407 let mut cache = lock_cache(cache)?;
408 evict_expired(&mut cache, token_window());
409 let cached = match cache.get(token) {
410 Some((_, cached_hash, _)) if *cached_hash != hash => {
411 return Err(DynoxideError::IdempotentParameterMismatchException(
412 "An error occurred (IdempotentParameterMismatchException)".to_string(),
413 ));
414 }
415 Some((_, _, slot)) => slot.clone(),
419 None => None,
420 };
421 if let Some(cached) = cached {
422 drop(cache);
424 return Ok(replay(&cached));
425 }
426 let resp = execute()?;
430 cache.insert(
431 token.to_string(),
432 (Instant::now(), hash, Some(resp.clone())),
433 );
434 Ok(resp)
435}
436
437#[cfg(any(feature = "wasm-sqlite", test))]
440const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
441
442#[cfg(any(feature = "wasm-sqlite", test))]
444enum TokenClaim<T> {
445 Marked(Instant),
449 InFlight,
451 Mismatch,
453 Hit(T),
455}
456
457#[cfg(any(feature = "wasm-sqlite", test))]
463fn lookup_or_claim<T: Clone>(
464 cache: &Mutex<TokenCache<T>>,
465 token: &str,
466 hash: u64,
467) -> Result<TokenClaim<T>> {
468 lookup_or_claim_within(cache, token, hash, token_window())
469}
470
471#[cfg(any(feature = "wasm-sqlite", test))]
474fn lookup_or_claim_within<T: Clone>(
475 cache: &Mutex<TokenCache<T>>,
476 token: &str,
477 hash: u64,
478 window: Duration,
479) -> Result<TokenClaim<T>> {
480 let mut cache = lock_cache(cache)?;
481 evict_expired(&mut cache, window);
482 Ok(match cache.get(token) {
483 Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
484 Some((_, _, None)) => TokenClaim::InFlight,
485 Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
486 None => {
487 let claimed_at = Instant::now();
488 cache.insert(token.to_string(), (claimed_at, hash, None));
489 TokenClaim::Marked(claimed_at)
490 }
491 })
492}
493
494#[cfg(any(feature = "wasm-sqlite", test))]
501fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
502 matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
503}
504
505#[cfg(any(feature = "wasm-sqlite", test))]
507fn record_complete<T>(
508 cache: &Mutex<TokenCache<T>>,
509 token: &str,
510 claimed_at: Instant,
511 hash: u64,
512 resp: T,
513) -> Result<()> {
514 let mut cache = lock_cache(cache)?;
515 if still_ours(&cache, token, claimed_at) {
516 cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
517 }
518 Ok(())
519}
520
521#[cfg(any(feature = "wasm-sqlite", test))]
524fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
525 let mut cache = lock_cache(cache)?;
526 if still_ours(&cache, token, claimed_at) {
527 cache.remove(token);
528 }
529 Ok(())
530}
531
532#[cfg(any(feature = "wasm-sqlite", test))]
545async fn run_idempotent_async<T, H, F, R>(
546 cache: &Mutex<TokenCache<T>>,
547 token: Option<&str>,
548 hash_input: &H,
549 execute: F,
550 replay: R,
551) -> Result<T>
552where
553 T: Clone,
554 H: serde::Serialize,
555 F: std::future::Future<Output = Result<T>>,
556 R: FnOnce(&T) -> T,
557{
558 validate_token(token)?;
559
560 let Some(token) = token else {
561 return execute.await;
562 };
563 let hash = request_hash(hash_input);
564
565 let claimed_at = match lookup_or_claim(cache, token, hash)? {
566 TokenClaim::Hit(cached) => return Ok(replay(&cached)),
567 TokenClaim::Mismatch => {
568 return Err(DynoxideError::IdempotentParameterMismatchException(
569 "An error occurred (IdempotentParameterMismatchException)".to_string(),
570 ));
571 }
572 TokenClaim::InFlight => {
573 return Err(DynoxideError::InternalServerError(
574 IN_FLIGHT_MESSAGE.to_string(),
575 ));
576 }
577 TokenClaim::Marked(claimed_at) => claimed_at,
578 };
579
580 match execute.await {
581 Ok(resp) => {
582 let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
586 Ok(resp)
587 }
588 Err(e) => {
589 let _ = clear_claim(cache, token, claimed_at);
590 Err(e)
591 }
592 }
593}
594
595#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
600pub type RusqliteBackend = storage::Storage;
601
602#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
610pub type NativeDatabase = Database<RusqliteBackend>;
611
612#[cfg(feature = "wasm-sqlite")]
619pub type WasmDatabase = Database<WasmBridgeBackend>;
620
621#[cfg(feature = "wasm-sqlite")]
633pub const WASM_BACKEND: bool = true;
634#[cfg(not(feature = "wasm-sqlite"))]
637pub const WASM_BACKEND: bool = false;
638
639#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
650pub struct Database<S = RusqliteBackend> {
651 inner: Arc<handoff_mutex::HandoffMutex<S>>,
652 tokens: Arc<TokenCaches>,
653 vector_lifecycle: Arc<VectorIndexLifecycle>,
654}
655
656#[cfg(all(
662 not(any(feature = "native-sqlite", feature = "_has-encryption")),
663 feature = "wasm-sqlite"
664))]
665use async_lock::Mutex as BackendMutex;
666#[cfg(all(
667 not(any(feature = "native-sqlite", feature = "_has-encryption")),
668 not(feature = "wasm-sqlite")
669))]
670use std::sync::Mutex as BackendMutex;
671
672#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
682pub struct Database<S> {
683 inner: Arc<BackendMutex<S>>,
684 tokens: Arc<TokenCaches>,
685 vector_lifecycle: Arc<VectorIndexLifecycle>,
686}
687
688impl<S> Clone for Database<S> {
690 fn clone(&self) -> Self {
691 Self {
692 inner: Arc::clone(&self.inner),
693 tokens: Arc::clone(&self.tokens),
694 vector_lifecycle: Arc::clone(&self.vector_lifecycle),
695 }
696 }
697}
698
699#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
700impl Database<RusqliteBackend> {
701 pub fn new(path: &str) -> Result<Self> {
703 let storage = storage::Storage::new(path)?;
704 Ok(Self {
705 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
706 tokens: Arc::new(TokenCaches::new()),
707 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
708 })
709 }
710
711 #[cfg(feature = "_has-encryption")]
732 pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
733 if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
734 return Err(DynoxideError::ValidationException(
735 "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
736 ));
737 }
738
739 let storage = storage::Storage::new_encrypted(path, key)?;
740 Ok(Self {
741 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
742 tokens: Arc::new(TokenCaches::new()),
743 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
744 })
745 }
746
747 pub fn memory() -> Result<Self> {
749 let storage = storage::Storage::memory()?;
750 Ok(Self {
751 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
752 tokens: Arc::new(TokenCaches::new()),
753 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
754 })
755 }
756
757 pub fn memory_with_clock(clock: Arc<dyn storage_backend::Clock>) -> Result<Self> {
764 let storage = storage::Storage::memory()?.with_clock(clock);
765 Ok(Self {
766 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
767 tokens: Arc::new(TokenCaches::new()),
768 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
769 })
770 }
771
772 pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
774 where
775 F: FnOnce(&storage::Storage) -> Result<T>,
776 {
777 let guard = self
778 .inner
779 .lock()
780 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
781 f(&guard)
782 }
783
784 pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
786 where
787 F: FnOnce(&mut storage::Storage) -> Result<T>,
788 {
789 let mut guard = self
790 .inner
791 .lock()
792 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
793 f(&mut guard)
794 }
795
796 pub fn create_table(
802 &self,
803 request: actions::create_table::CreateTableRequest,
804 ) -> Result<actions::create_table::CreateTableResponse> {
805 self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
806 }
807
808 pub fn delete_table(
810 &self,
811 request: actions::delete_table::DeleteTableRequest,
812 ) -> Result<actions::delete_table::DeleteTableResponse> {
813 self.with_storage(|s| {
814 pollster::block_on(actions::delete_table::execute(
815 s,
816 request,
817 &self.vector_lifecycle,
818 ))
819 })
820 }
821
822 pub fn describe_table(
824 &self,
825 request: actions::describe_table::DescribeTableRequest,
826 ) -> Result<actions::describe_table::DescribeTableResponse> {
827 self.with_storage(|s| {
828 pollster::block_on(actions::describe_table::execute(
829 s,
830 request,
831 &self.vector_lifecycle,
832 ))
833 })
834 }
835
836 pub fn update_table(
838 &self,
839 request: actions::update_table::UpdateTableRequest,
840 ) -> Result<actions::update_table::UpdateTableResponse> {
841 self.with_storage(|s| {
842 pollster::block_on(actions::update_table::execute(
843 s,
844 request,
845 &self.vector_lifecycle,
846 ))
847 })
848 }
849
850 pub fn list_tables(
852 &self,
853 request: actions::list_tables::ListTablesRequest,
854 ) -> Result<actions::list_tables::ListTablesResponse> {
855 self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
856 }
857
858 pub fn tag_resource(
864 &self,
865 request: actions::tag_resource::TagResourceRequest,
866 ) -> Result<actions::tag_resource::TagResourceResponse> {
867 self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
868 }
869
870 pub fn untag_resource(
872 &self,
873 request: actions::untag_resource::UntagResourceRequest,
874 ) -> Result<actions::untag_resource::UntagResourceResponse> {
875 self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
876 }
877
878 pub fn list_tags_of_resource(
880 &self,
881 request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
882 ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
883 self.with_storage(|s| {
884 pollster::block_on(actions::list_tags_of_resource::execute(s, request))
885 })
886 }
887
888 pub fn put_item(
894 &self,
895 request: actions::put_item::PutItemRequest,
896 ) -> Result<actions::put_item::PutItemResponse> {
897 self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
898 }
899
900 pub fn get_item(
902 &self,
903 request: actions::get_item::GetItemRequest,
904 ) -> Result<actions::get_item::GetItemResponse> {
905 self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
906 }
907
908 pub fn delete_item(
910 &self,
911 request: actions::delete_item::DeleteItemRequest,
912 ) -> Result<actions::delete_item::DeleteItemResponse> {
913 self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
914 }
915
916 pub fn update_item(
918 &self,
919 request: actions::update_item::UpdateItemRequest,
920 ) -> Result<actions::update_item::UpdateItemResponse> {
921 self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
922 }
923
924 pub fn batch_get_item(
930 &self,
931 request: actions::batch_get_item::BatchGetItemRequest,
932 ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
933 self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
934 }
935
936 pub fn batch_write_item(
938 &self,
939 request: actions::batch_write_item::BatchWriteItemRequest,
940 ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
941 self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
942 }
943
944 pub fn import_items(
959 &self,
960 table_name: &str,
961 items: Vec<Item>,
962 options: ImportOptions,
963 ) -> Result<ImportResult> {
964 self.with_storage(|s| {
965 pollster::block_on(actions::import_items::execute(
966 s, table_name, items, &options,
967 ))
968 })
969 }
970
971 #[cfg(feature = "import")]
977 pub(crate) fn import_items_fresh(
978 &self,
979 table_name: &str,
980 items: Vec<Item>,
981 options: ImportOptions,
982 ) -> Result<ImportResult> {
983 self.with_storage(|s| {
984 pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
985 s, table_name, items, &options,
986 ))
987 })
988 }
989
990 pub fn enable_bulk_loading(&self) -> Result<()> {
999 self.with_storage(|s| s.enable_bulk_loading())
1000 }
1001
1002 pub fn disable_bulk_loading(&self) -> Result<()> {
1004 self.with_storage(|s| s.disable_bulk_loading())
1005 }
1006
1007 pub fn query(
1013 &self,
1014 request: actions::query::QueryRequest,
1015 ) -> Result<actions::query::QueryResponse> {
1016 self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
1017 }
1018
1019 pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
1021 self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
1022 }
1023
1024 pub fn search_vectors(
1026 &self,
1027 request: actions::search_vectors::SearchVectorsRequest,
1028 ) -> Result<actions::search_vectors::SearchVectorsResponse> {
1029 self.with_storage(|s| {
1030 pollster::block_on(actions::search_vectors::execute(
1031 s,
1032 request,
1033 &self.vector_lifecycle,
1034 ))
1035 })
1036 }
1037
1038 pub fn transact_write_items(
1049 &self,
1050 request: actions::transact_write_items::TransactWriteItemsRequest,
1051 ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
1052 actions::transact_write_items::check_request(&request)?;
1056 run_idempotent(
1057 &self.tokens.transact_write,
1058 request.client_request_token.as_deref(),
1059 &request.transact_items,
1060 || {
1061 self.with_storage(|s| {
1062 pollster::block_on(actions::transact_write_items::execute_cached(
1063 s,
1064 request.clone(),
1065 ))
1066 })
1067 },
1068 |cached| {
1069 actions::transact_write_items::replay_response(
1074 cached,
1075 &request.return_consumed_capacity,
1076 )
1077 },
1078 )
1079 .map(|cached| cached.response)
1080 }
1081
1082 pub fn transact_get_items(
1084 &self,
1085 request: actions::transact_get_items::TransactGetItemsRequest,
1086 ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
1087 self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
1088 }
1089
1090 pub fn list_streams(
1096 &self,
1097 request: actions::list_streams::ListStreamsRequest,
1098 ) -> Result<actions::list_streams::ListStreamsResponse> {
1099 self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
1100 }
1101
1102 pub fn describe_stream(
1104 &self,
1105 request: actions::describe_stream::DescribeStreamRequest,
1106 ) -> Result<actions::describe_stream::DescribeStreamResponse> {
1107 self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
1108 }
1109
1110 pub fn get_shard_iterator(
1112 &self,
1113 request: actions::get_shard_iterator::GetShardIteratorRequest,
1114 ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
1115 self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
1116 }
1117
1118 pub fn get_records(
1120 &self,
1121 request: actions::get_records::GetRecordsRequest,
1122 ) -> Result<actions::get_records::GetRecordsResponse> {
1123 self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
1124 }
1125
1126 pub fn update_time_to_live(
1132 &self,
1133 request: actions::update_time_to_live::UpdateTimeToLiveRequest,
1134 ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
1135 self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
1136 }
1137
1138 pub fn describe_time_to_live(
1140 &self,
1141 request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
1142 ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
1143 self.with_storage(|s| {
1144 pollster::block_on(actions::describe_time_to_live::execute(s, request))
1145 })
1146 }
1147
1148 pub fn sweep_ttl(&self) -> Result<usize> {
1163 self.sweep_ttl_by_page(|_| {}, |_| {})
1164 }
1165
1166 fn sweep_ttl_by_page(
1171 &self,
1172 mut in_page: impl FnMut(usize),
1173 mut between_pages: impl FnMut(usize),
1174 ) -> Result<usize> {
1175 let mut sweep = self.with_storage(|s| pollster::block_on(ttl::Sweep::begin(s)))?;
1176 let mut page = 0;
1177 loop {
1178 page += 1;
1179 let hand_off = self.with_storage(|s| {
1182 let more = pollster::block_on(sweep.next_page(s))?;
1183 in_page(page);
1184 Ok(more.then(|| self.inner.start_hand_off()))
1185 })?;
1186 let Some(hand_off) = hand_off else {
1187 return Ok(sweep.deleted());
1188 };
1189 hand_off.wait();
1190 between_pages(page);
1191 }
1192 }
1193
1194 pub fn execute_statement(
1200 &self,
1201 request: actions::execute_statement::ExecuteStatementRequest,
1202 ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
1203 self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
1204 }
1205
1206 pub fn execute_transaction(
1214 &self,
1215 request: actions::execute_transaction::ExecuteTransactionRequest,
1216 ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
1217 actions::execute_transaction::check_members(&request)?;
1222 run_idempotent(
1223 &self.tokens.execute_transaction,
1224 request.client_request_token.as_deref(),
1225 &actions::execute_transaction::token_statements(&request.transact_statements),
1226 || {
1227 self.with_storage(|s| {
1228 pollster::block_on(actions::execute_transaction::execute_cached(
1229 s,
1230 request.clone(),
1231 ))
1232 })
1233 },
1234 |cached| {
1235 actions::execute_transaction::replay_response(
1238 cached,
1239 &request.return_consumed_capacity,
1240 )
1241 },
1242 )
1243 .map(|cached| cached.response)
1244 }
1245
1246 pub fn batch_execute_statement(
1248 &self,
1249 request: actions::batch_execute_statement::BatchExecuteStatementRequest,
1250 ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
1251 self.with_storage(|s| {
1252 pollster::block_on(actions::batch_execute_statement::execute(s, request))
1253 })
1254 }
1255
1256 pub fn touch_cached_at(
1265 &self,
1266 table_name: &str,
1267 pk: &str,
1268 sk: &str,
1269 timestamp: f64,
1270 ) -> Result<()> {
1271 self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
1272 }
1273
1274 pub fn get_lru_items(
1279 &self,
1280 table_name: &str,
1281 limit: usize,
1282 ) -> Result<Vec<(String, String, i64)>> {
1283 self.with_storage(|s| s.get_lru_items(table_name, limit))
1284 }
1285
1286 pub fn db_path(&self) -> Result<Option<String>> {
1292 self.with_storage(|s| Ok(s.db_path()))
1293 }
1294
1295 pub fn db_size_bytes(&self) -> Result<u64> {
1297 self.with_storage(|s| s.db_size_bytes())
1298 }
1299
1300 pub fn table_count(&self) -> Result<usize> {
1302 self.with_storage(|s| s.table_count())
1303 }
1304
1305 pub fn table_stats(&self) -> Result<Vec<TableStats>> {
1307 self.with_storage(|s| s.table_stats())
1308 }
1309
1310 pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
1312 self.with_storage(|s| s.get_table_metadata(table_name))
1313 }
1314
1315 pub fn database_info(&self) -> Result<DatabaseInfo> {
1320 self.with_storage(|s| s.database_info())
1321 }
1322
1323 pub fn vacuum(&self) -> Result<()> {
1329 self.with_storage(|s| s.vacuum())
1330 }
1331
1332 pub fn vacuum_into(&self, path: &str) -> Result<()> {
1337 self.with_storage(|s| s.vacuum_into(path))
1338 }
1339
1340 pub fn restore_from(&self, path: &str) -> Result<()> {
1353 self.restore_with(|s| s.restore_from(path))
1354 }
1355
1356 fn restore_with(
1365 &self,
1366 restore: impl FnOnce(&mut storage::Storage) -> Result<()>,
1367 ) -> Result<()> {
1368 let mut tokens = self.tokens.lock_all();
1369 self.with_storage_mut(|s| {
1370 restore(s)?;
1371 tokens.clear();
1372 self.vector_lifecycle.forget_all();
1373 Ok(())
1374 })
1375 }
1376
1377 #[cfg(feature = "mcp-server")]
1382 pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
1383 self.with_storage(|s| s.backup_to_memory())
1384 }
1385
1386 #[cfg(feature = "mcp-server")]
1392 pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
1393 self.restore_with(|s| s.restore_from_connection(source))
1394 }
1395}
1396
1397#[cfg(feature = "wasm-sqlite")]
1411impl Database<WasmBridgeBackend> {
1412 pub async fn open(name: &str) -> Result<Self> {
1415 Self::open_with(name, false).await
1416 }
1417
1418 pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
1421 let backend = WasmBridgeBackend::open_with(name, ephemeral)
1422 .await
1423 .map_err(DynoxideError::from)?;
1424 Ok(Self {
1425 inner: Arc::new(BackendMutex::new(backend)),
1426 tokens: Arc::new(TokenCaches::new()),
1427 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
1428 })
1429 }
1430
1431 pub async fn persistence_mode(&self) -> String {
1433 self.backend().await.persistence_mode().to_string()
1434 }
1435
1436 pub async fn close(&self) -> Result<()> {
1440 self.backend()
1441 .await
1442 .close()
1443 .await
1444 .map_err(DynoxideError::from)
1445 }
1446
1447 pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
1456 self.inner.lock().await
1457 }
1458
1459 pub(crate) fn token_caches(&self) -> &TokenCaches {
1463 &self.tokens
1464 }
1465
1466 pub(crate) fn vector_lifecycle(&self) -> &VectorIndexLifecycle {
1470 &self.vector_lifecycle
1471 }
1472
1473 pub async fn create_table(
1475 &self,
1476 request: actions::create_table::CreateTableRequest,
1477 ) -> Result<actions::create_table::CreateTableResponse> {
1478 let backend = self.backend().await;
1479 actions::create_table::execute(&*backend, request).await
1480 }
1481
1482 pub async fn delete_table(
1484 &self,
1485 request: actions::delete_table::DeleteTableRequest,
1486 ) -> Result<actions::delete_table::DeleteTableResponse> {
1487 let backend = self.backend().await;
1488 actions::delete_table::execute(&*backend, request, &self.vector_lifecycle).await
1489 }
1490
1491 pub async fn describe_table(
1493 &self,
1494 request: actions::describe_table::DescribeTableRequest,
1495 ) -> Result<actions::describe_table::DescribeTableResponse> {
1496 let backend = self.backend().await;
1497 actions::describe_table::execute(&*backend, request, &self.vector_lifecycle).await
1498 }
1499
1500 pub async fn list_tables(
1502 &self,
1503 request: actions::list_tables::ListTablesRequest,
1504 ) -> Result<actions::list_tables::ListTablesResponse> {
1505 let backend = self.backend().await;
1506 actions::list_tables::execute(&*backend, request).await
1507 }
1508
1509 pub async fn put_item(
1511 &self,
1512 request: actions::put_item::PutItemRequest,
1513 ) -> Result<actions::put_item::PutItemResponse> {
1514 let backend = self.backend().await;
1515 actions::put_item::execute(&*backend, request).await
1516 }
1517
1518 pub async fn get_item(
1520 &self,
1521 request: actions::get_item::GetItemRequest,
1522 ) -> Result<actions::get_item::GetItemResponse> {
1523 let backend = self.backend().await;
1524 actions::get_item::execute(&*backend, request).await
1525 }
1526
1527 pub async fn delete_item(
1529 &self,
1530 request: actions::delete_item::DeleteItemRequest,
1531 ) -> Result<actions::delete_item::DeleteItemResponse> {
1532 let backend = self.backend().await;
1533 actions::delete_item::execute(&*backend, request).await
1534 }
1535
1536 pub async fn query(
1538 &self,
1539 request: actions::query::QueryRequest,
1540 ) -> Result<actions::query::QueryResponse> {
1541 let backend = self.backend().await;
1542 actions::query::execute(&*backend, request).await
1543 }
1544
1545 pub async fn scan(
1547 &self,
1548 request: actions::scan::ScanRequest,
1549 ) -> Result<actions::scan::ScanResponse> {
1550 let backend = self.backend().await;
1551 actions::scan::execute(&*backend, request).await
1552 }
1553
1554 pub async fn search_vectors(
1558 &self,
1559 request: actions::search_vectors::SearchVectorsRequest,
1560 ) -> Result<actions::search_vectors::SearchVectorsResponse> {
1561 let backend = self.backend().await;
1562 actions::search_vectors::execute(&*backend, request, &self.vector_lifecycle).await
1563 }
1564}
1565
1566#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1567mod tests {
1568 use super::*;
1569
1570 fn database_with_snapshot(dir: &tempfile::TempDir) -> (Database, String) {
1572 let snapshot = dir.path().join("before.db");
1573 let snapshot = snapshot.to_str().unwrap().to_string();
1574 let db = Database::memory().unwrap();
1575 db.create_table(
1576 serde_json::from_value(serde_json::json!({
1577 "TableName": "tbl",
1578 "BillingMode": "PAY_PER_REQUEST",
1579 "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1580 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1581 }))
1582 .unwrap(),
1583 )
1584 .unwrap();
1585 db.vacuum_into(&snapshot).unwrap();
1586 (db, snapshot)
1587 }
1588
1589 fn assert_restore_keeps_cache_then_storage_order<T: Send + 'static>(
1596 db: &Database,
1597 cache: &Mutex<TokenCache<T>>,
1598 restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
1599 ) {
1600 let held = cache.lock().unwrap();
1601 let (ready, is_ready) = std::sync::mpsc::channel();
1602 let restorer = {
1603 let db = db.clone();
1604 std::thread::spawn(move || {
1605 let _ = ready.send(());
1606 restore(db)
1607 })
1608 };
1609 is_ready.recv().unwrap();
1610 let (sent, got) = std::sync::mpsc::channel();
1611 {
1612 let db = db.clone();
1613 std::thread::spawn(move || {
1614 let _ = sent.send(db.with_storage(|_| Ok(())).is_ok());
1615 });
1616 }
1617 let storage_step = got.recv_timeout(std::time::Duration::from_secs(5));
1618 let restore_waited = !restorer.is_finished();
1619 drop(held);
1620 assert!(
1621 matches!(storage_step, Ok(true)),
1622 "a transaction holding its token cache must still get the storage lock while a restore waits"
1623 );
1624 assert!(
1625 restore_waited,
1626 "the restore must wait for the token cache the transaction holds"
1627 );
1628 restorer
1629 .join()
1630 .unwrap()
1631 .expect("the restore finishes once the cache is free");
1632 }
1633
1634 fn assert_restore_takes_cache_before_storage<T: Send + 'static>(
1639 db: &Database,
1640 cache: &Mutex<TokenCache<T>>,
1641 restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
1642 ) {
1643 let storage = db.inner.lock().unwrap();
1644 let restorer = {
1645 let db = db.clone();
1646 std::thread::spawn(move || restore(db))
1647 };
1648 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1649 while db.inner.waiting() == 0 {
1650 assert!(
1651 std::time::Instant::now() < deadline,
1652 "the restore never asked for the storage lock"
1653 );
1654 std::thread::yield_now();
1655 }
1656 let cache_was_free = cache.try_lock().is_ok();
1657 drop(storage);
1658 restorer
1659 .join()
1660 .unwrap()
1661 .expect("the restore finishes once storage is free");
1662 assert!(
1663 !cache_was_free,
1664 "a restore must hold the token caches before it waits for storage"
1665 );
1666 }
1667
1668 #[test]
1669 fn a_restore_cannot_deadlock_against_a_tokened_transaction() {
1670 let dir = tempfile::tempdir().unwrap();
1671 let (db, snapshot) = database_with_snapshot(&dir);
1672 let restore = |path: &String| {
1673 let path = path.clone();
1674 move |db: Database| db.restore_from(&path)
1675 };
1676 assert_restore_keeps_cache_then_storage_order(
1677 &db,
1678 &db.tokens.transact_write,
1679 restore(&snapshot),
1680 );
1681 assert_restore_keeps_cache_then_storage_order(
1682 &db,
1683 &db.tokens.execute_transaction,
1684 restore(&snapshot),
1685 );
1686 assert_restore_takes_cache_before_storage(
1687 &db,
1688 &db.tokens.transact_write,
1689 restore(&snapshot),
1690 );
1691 assert_restore_takes_cache_before_storage(
1692 &db,
1693 &db.tokens.execute_transaction,
1694 restore(&snapshot),
1695 );
1696 }
1697
1698 #[cfg(feature = "mcp-server")]
1701 fn connection_restore(db: &Database) -> impl FnOnce(Database) -> Result<()> + Send + 'static {
1702 let copy = db.backup_to_memory().unwrap();
1703 move |db: Database| db.restore_from_connection(©)
1704 }
1705
1706 #[cfg(feature = "mcp-server")]
1707 #[test]
1708 fn a_connection_restore_cannot_deadlock_against_a_tokened_transaction() {
1709 let dir = tempfile::tempdir().unwrap();
1710 let (db, _) = database_with_snapshot(&dir);
1711 assert_restore_keeps_cache_then_storage_order(
1712 &db,
1713 &db.tokens.transact_write,
1714 connection_restore(&db),
1715 );
1716 assert_restore_keeps_cache_then_storage_order(
1717 &db,
1718 &db.tokens.execute_transaction,
1719 connection_restore(&db),
1720 );
1721 assert_restore_takes_cache_before_storage(
1722 &db,
1723 &db.tokens.transact_write,
1724 connection_restore(&db),
1725 );
1726 assert_restore_takes_cache_before_storage(
1727 &db,
1728 &db.tokens.execute_transaction,
1729 connection_restore(&db),
1730 );
1731 }
1732
1733 #[test]
1734 fn a_restore_forgets_cached_transactions_and_vector_windows() {
1735 let dir = tempfile::tempdir().unwrap();
1736 let snapshot = dir.path().join("before.db");
1737 let snapshot = snapshot.to_str().unwrap();
1738 let db = Database::memory().unwrap();
1739 db.create_table(
1740 serde_json::from_value(serde_json::json!({
1741 "TableName": "tbl",
1742 "BillingMode": "PAY_PER_REQUEST",
1743 "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1744 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1745 }))
1746 .unwrap(),
1747 )
1748 .unwrap();
1749 db.vacuum_into(snapshot).unwrap();
1750
1751 let put = || {
1752 serde_json::from_value::<actions::transact_write_items::TransactWriteItemsRequest>(
1753 serde_json::json!({
1754 "ClientRequestToken": "same-token",
1755 "TransactItems": [{"Put": {"TableName": "tbl", "Item": {"pk": {"S": "a"}}}}]
1756 }),
1757 )
1758 .unwrap()
1759 };
1760 db.transact_write_items(put()).unwrap();
1761 db.with_storage(|s| {
1762 db.vector_lifecycle.arm(s, "tbl", "v");
1763 Ok(())
1764 })
1765 .unwrap();
1766 assert!(!db.vector_lifecycle.is_empty());
1767
1768 db.restore_from(snapshot).unwrap();
1769 assert!(
1770 db.vector_lifecycle.is_empty(),
1771 "the restored tables' creation windows must not survive"
1772 );
1773
1774 db.transact_write_items(put()).unwrap();
1777 let got = db
1778 .get_item(
1779 serde_json::from_value(serde_json::json!({
1780 "TableName": "tbl",
1781 "Key": {"pk": {"S": "a"}}
1782 }))
1783 .unwrap(),
1784 )
1785 .unwrap();
1786 assert!(
1787 got.item.is_some(),
1788 "the retried transaction must have written"
1789 );
1790 }
1791
1792 #[test]
1793 fn an_unrelated_update_leaves_an_unsorted_stored_set_under_an_index_key_alone() {
1794 let db = Database::memory().unwrap();
1795 db.create_table(
1796 serde_json::from_value(serde_json::json!({
1797 "TableName": "tbl",
1798 "BillingMode": "PAY_PER_REQUEST",
1799 "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1800 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1801 }))
1802 .unwrap(),
1803 )
1804 .unwrap();
1805 db.put_item(
1806 serde_json::from_value(serde_json::json!({
1807 "TableName": "tbl",
1808 "Item": {"pk": {"S": "k"}, "g": {"SS": ["a", "b"]}}
1809 }))
1810 .unwrap(),
1811 )
1812 .unwrap();
1813 let stored: String = db
1815 .with_storage(|s| {
1816 s.conn().execute(
1817 "UPDATE \"tbl\" SET item_json = replace(item_json, '\"SS\":[\"a\",\"b\"]', '\"SS\":[\"b\",\"a\"]') \
1818 WHERE item_json LIKE '%\"SS\":[\"a\",\"b\"]%'",
1819 [],
1820 )?;
1821 Ok(s.conn()
1822 .query_row("SELECT item_json FROM \"tbl\"", [], |row| row.get(0))?)
1823 })
1824 .unwrap();
1825 assert!(
1826 stored.contains(r#""SS":["b","a"]"#),
1827 "the fixture must hold an unsorted set: {stored}"
1828 );
1829 db.update_table(
1832 serde_json::from_value(serde_json::json!({
1833 "TableName": "tbl",
1834 "AttributeDefinitions": [{"AttributeName": "g", "AttributeType": "S"}],
1835 "GlobalSecondaryIndexUpdates": [{"Create": {
1836 "IndexName": "byG",
1837 "KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}],
1838 "Projection": {"ProjectionType": "ALL"}
1839 }}]
1840 }))
1841 .unwrap(),
1842 )
1843 .unwrap();
1844
1845 db.update_item(
1846 serde_json::from_value(serde_json::json!({
1847 "TableName": "tbl",
1848 "Key": {"pk": {"S": "k"}},
1849 "UpdateExpression": "SET note = :v",
1850 "ExpressionAttributeValues": {":v": {"S": "x"}}
1851 }))
1852 .unwrap(),
1853 )
1854 .expect("an update that doesn't touch g must not re-check g against the index");
1855 }
1856
1857 #[test]
1858 fn test_database_memory() {
1859 let db = Database::memory().unwrap();
1860 let _db2 = db.clone();
1862 }
1863
1864 #[test]
1865 fn test_database_with_storage() {
1866 let db = Database::memory().unwrap();
1867 let tables = db.with_storage(|s| s.list_table_names()).unwrap();
1868 assert!(tables.is_empty());
1869 }
1870
1871 #[test]
1872 fn test_database_thread_safe() {
1873 let db = Database::memory().unwrap();
1874 let db2 = db.clone();
1875
1876 let handle =
1877 std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
1878
1879 let tables = handle.join().unwrap();
1880 assert!(tables.is_empty());
1881 }
1882
1883 #[test]
1884 fn test_native_database_alias_round_trips() {
1885 let db: NativeDatabase = Database::memory().unwrap();
1889
1890 db.create_table(actions::create_table::CreateTableRequest {
1891 table_name: "tbl".to_string(),
1892 key_schema: vec![types::KeySchemaElement {
1893 attribute_name: "pk".to_string(),
1894 key_type: types::KeyType::HASH,
1895 }],
1896 attribute_definitions: vec![types::AttributeDefinition {
1897 attribute_name: "pk".to_string(),
1898 attribute_type: types::ScalarAttributeType::S,
1899 }],
1900 ..Default::default()
1901 })
1902 .unwrap();
1903
1904 let mut item = HashMap::new();
1905 item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1906 db.put_item(actions::put_item::PutItemRequest {
1907 table_name: "tbl".to_string(),
1908 item,
1909 ..Default::default()
1910 })
1911 .unwrap();
1912
1913 let mut key = HashMap::new();
1914 key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1915 let got = db
1916 .get_item(actions::get_item::GetItemRequest {
1917 table_name: "tbl".to_string(),
1918 key,
1919 ..Default::default()
1920 })
1921 .unwrap();
1922 assert_eq!(
1923 got.item.unwrap().get("pk"),
1924 Some(&AttributeValue::S("a".to_string()))
1925 );
1926 }
1927}
1928
1929#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1932mod ttl_sweep_tests;
1933
1934#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1937mod transaction_storage_error_tests;
1938
1939#[cfg(test)]
1945mod idempotency_tests {
1946 use super::*;
1947 use std::cell::Cell;
1948
1949 const KEY: &str = "statements";
1950 const TOKEN: &str = "tok";
1951
1952 fn cache() -> Mutex<TokenCache<u32>> {
1953 Mutex::new(HashMap::new())
1954 }
1955
1956 fn hash() -> u64 {
1957 request_hash(&KEY)
1958 }
1959
1960 fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
1961 where
1962 F: std::future::Future<Output = Result<u32>>,
1963 {
1964 pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
1965 }
1966
1967 #[test]
1968 fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
1969 let cache = cache();
1970
1971 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1972 panic!("the first caller should have claimed the token");
1973 };
1974 assert!(matches!(
1975 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1976 TokenClaim::InFlight
1977 ));
1978
1979 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1980 assert!(matches!(
1981 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1982 TokenClaim::Hit(7)
1983 ));
1984 }
1985
1986 #[test]
1987 fn only_one_caller_can_claim_a_token() {
1988 use std::sync::Barrier;
1992
1993 const N: usize = 16;
1994 let cache = cache();
1995 let barrier = Barrier::new(N);
1996
1997 let marked = std::thread::scope(|scope| {
1998 let handles: Vec<_> = (0..N)
1999 .map(|_| {
2000 scope.spawn(|| {
2001 barrier.wait();
2002 matches!(
2003 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2004 TokenClaim::Marked(_)
2005 )
2006 })
2007 })
2008 .collect();
2009 handles
2010 .into_iter()
2011 .map(|h| h.join().unwrap())
2012 .filter(|claimed| *claimed)
2013 .count()
2014 });
2015
2016 assert_eq!(marked, 1);
2017 }
2018
2019 #[test]
2020 fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
2021 let cache = cache();
2025 let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2026 panic!("the first caller should have claimed the token");
2027 };
2028 let TokenClaim::Marked(second) =
2029 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
2030 else {
2031 panic!("the expired claim should have been re-issued");
2032 };
2033 assert_ne!(first, second);
2034
2035 record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
2037 assert!(matches!(
2038 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2039 TokenClaim::InFlight
2040 ));
2041
2042 clear_claim(&cache, TOKEN, first).unwrap();
2044 assert!(matches!(
2045 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2046 TokenClaim::InFlight
2047 ));
2048
2049 record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
2051 assert!(matches!(
2052 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2053 TokenClaim::Hit(2)
2054 ));
2055 }
2056
2057 #[test]
2064 fn a_request_hash_ignores_map_key_order() {
2065 use crate::types::AttributeValue;
2066 let item = |n: &str| -> HashMap<String, AttributeValue> {
2067 let inner: HashMap<String, AttributeValue> = (0..16)
2068 .map(|i| (format!("i{i}"), AttributeValue::N(n.to_string())))
2069 .collect();
2070 (0..16)
2071 .map(|i| (format!("k{i}"), AttributeValue::M(inner.clone())))
2072 .collect()
2073 };
2074 for _ in 0..8 {
2075 assert_eq!(
2076 request_hash(&vec![item("1")]),
2077 request_hash(&vec![item("1")])
2078 );
2079 }
2080 assert_ne!(
2081 request_hash(&vec![item("1")]),
2082 request_hash(&vec![item("2")])
2083 );
2084 }
2085
2086 #[test]
2087 fn a_different_request_under_the_same_token_mismatches_in_both_states() {
2088 let other = request_hash(&"different");
2089
2090 let cache = cache();
2091 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2092 panic!("expected a free slot to claim");
2093 };
2094 assert!(matches!(
2095 lookup_or_claim(&cache, TOKEN, other).unwrap(),
2096 TokenClaim::Mismatch
2097 ));
2098
2099 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
2100 assert!(matches!(
2101 lookup_or_claim(&cache, TOKEN, other).unwrap(),
2102 TokenClaim::Mismatch
2103 ));
2104 }
2105
2106 #[test]
2107 fn the_claim_lands_before_the_call_is_polled() {
2108 let cache = cache();
2110 let claimed_first = Cell::new(false);
2111
2112 let out = drive(&cache, Some(TOKEN), async {
2113 let slots = cache
2116 .try_lock()
2117 .expect("the cache lock must be released before the call runs");
2118 claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
2119 Ok(7)
2120 });
2121
2122 assert_eq!(out.unwrap(), 7);
2123 assert!(claimed_first.get());
2124 }
2125
2126 #[test]
2127 fn a_caller_arriving_under_a_live_claim_does_not_execute() {
2128 let cache = cache();
2129 let second_ran = Cell::new(false);
2130
2131 let out = drive(&cache, Some(TOKEN), async {
2132 let second = drive(&cache, Some(TOKEN), async {
2133 second_ran.set(true);
2134 Ok(0)
2135 });
2136 assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
2139 assert!(matches!(
2142 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2143 TokenClaim::InFlight
2144 ));
2145 Ok(1)
2146 });
2147
2148 assert_eq!(out.unwrap(), 1);
2149 assert!(!second_ran.get(), "the second call must not run the work");
2150 assert!(matches!(
2152 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2153 TokenClaim::Hit(1)
2154 ));
2155 }
2156
2157 #[test]
2158 fn the_synchronous_driver_treats_a_live_claim_as_absent() {
2159 let cache = cache();
2164 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
2165
2166 let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
2167 assert_eq!(
2168 out, 9,
2169 "a claimed slot is not treated as a replayable result"
2170 );
2171 }
2172
2173 #[test]
2174 fn a_settled_token_replays_without_re_executing() {
2175 let cache = cache();
2176 let runs = Cell::new(0);
2177
2178 for _ in 0..2 {
2179 let out = drive(&cache, Some(TOKEN), async {
2180 runs.set(runs.get() + 1);
2181 Ok(7)
2182 });
2183 assert_eq!(out.unwrap(), 7);
2184 }
2185 assert_eq!(runs.get(), 1);
2186 }
2187
2188 #[test]
2189 fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
2190 let cache = cache();
2191 let runs = Cell::new(0);
2192
2193 let first = drive(&cache, Some(TOKEN), async {
2194 runs.set(runs.get() + 1);
2195 Err(DynoxideError::ValidationException("no".into()))
2196 });
2197 assert!(first.is_err());
2198 assert!(
2199 cache.lock().unwrap().is_empty(),
2200 "the claim must be released"
2201 );
2202
2203 let second = drive(&cache, Some(TOKEN), async {
2204 runs.set(runs.get() + 1);
2205 Ok(7)
2206 });
2207 assert_eq!(second.unwrap(), 7);
2208 assert_eq!(runs.get(), 2);
2209 }
2210
2211 #[test]
2212 fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
2213 let cache = cache();
2216 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
2217
2218 assert!(matches!(
2219 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
2220 TokenClaim::Marked(_)
2221 ));
2222 }
2223
2224 #[test]
2225 fn a_settled_token_stops_replaying_once_it_expires() {
2226 let cache = cache();
2227 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2228 panic!("expected a free slot to claim");
2229 };
2230 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
2231
2232 assert!(matches!(
2234 lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
2235 TokenClaim::Hit(7)
2236 ));
2237 assert!(matches!(
2239 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
2240 TokenClaim::Marked(_)
2241 ));
2242 }
2243
2244 #[test]
2245 fn concurrent_callers_under_one_token_execute_the_work_once() {
2246 use std::sync::Barrier;
2250 use std::sync::atomic::{AtomicUsize, Ordering};
2251
2252 const N: usize = 16;
2253 let cache = cache();
2254 let runs = AtomicUsize::new(0);
2255 let barrier = Barrier::new(N);
2256
2257 let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
2258 let handles: Vec<_> = (0..N)
2259 .map(|_| {
2260 scope.spawn(|| {
2261 barrier.wait();
2262 drive(&cache, Some(TOKEN), async {
2263 runs.fetch_add(1, Ordering::SeqCst);
2264 Ok(7)
2265 })
2266 })
2267 })
2268 .collect();
2269 handles.into_iter().map(|h| h.join().unwrap()).collect()
2270 });
2271
2272 assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
2273 for outcome in outcomes {
2276 match outcome {
2277 Ok(v) => assert_eq!(v, 7),
2278 Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
2279 }
2280 }
2281 }
2282
2283 #[test]
2284 fn an_overlong_token_is_rejected_with_dynamodbs_message() {
2285 let token = "x".repeat(MAX_TOKEN_LEN + 1);
2286 let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
2287 assert_eq!(
2288 err.to_string(),
2289 format!(
2290 "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
2291 )
2292 );
2293 }
2294
2295 #[test]
2296 fn a_tokenless_call_never_touches_the_cache() {
2297 let cache = cache();
2298 let runs = Cell::new(0);
2299
2300 for _ in 0..2 {
2301 drive(&cache, None, async {
2302 runs.set(runs.get() + 1);
2303 Ok(7)
2304 })
2305 .unwrap();
2306 }
2307 assert_eq!(runs.get(), 2);
2308 assert!(cache.lock().unwrap().is_empty());
2309 }
2310}