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 feature = "http-server",
39 feature = "mcp-server",
40 feature = "import"
41 )
42))]
43compile_error!(
44 "The `wasm-sqlite` backend is mutually exclusive with the native backends and \
45 with the CLI, server and MCP features, which are native-only.\n\
46 If your manifest names none of those, they came from the defaults: Cargo adds \
47 the default features to whatever you list, so `features = [\"wasm-sqlite\"]` \
48 enables the native backend as well. Turn them off:\n \
49 dynoxide-rs = { version = \"...\", default-features = false, features = [\"wasm-sqlite\"] }"
50);
51
52#[cfg(not(any(
53 feature = "native-sqlite",
54 feature = "_has-encryption",
55 feature = "wasm-sqlite"
56)))]
57compile_error!(
58 "A storage backend feature must be enabled: `native-sqlite`, `encryption`, \
59 `encryption-cc`, or `wasm-sqlite`. Default features include `native-sqlite`. \
60 If you used `default-features = false`, add one of these features."
61);
62
63pub mod actions;
64pub mod auth_material;
65pub mod bench_counters;
66pub(crate) mod empty_names;
68pub mod errors;
69pub(crate) mod expressions;
70#[cfg(feature = "import")]
71pub mod import;
72#[doc(hidden)]
73pub mod macros;
74#[cfg(feature = "mcp-server")]
75pub mod mcp;
76#[cfg(any(feature = "http-server", feature = "mcp-server"))]
77pub(crate) mod net;
78pub(crate) mod partiql;
79pub mod schema;
80pub(crate) mod set_order;
82pub(crate) mod serde_errors;
86#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
90pub(crate) mod serialization_checks;
91pub(crate) mod value_nesting;
94#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
97pub(crate) mod deep_nesting;
98#[cfg(feature = "http-server")]
99pub mod server;
100#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
103pub(crate) mod handoff_mutex;
104
105#[cfg(any(feature = "http-server", feature = "mcp-server"))]
108mod server_options;
109#[cfg(feature = "mcp-server")]
110pub(crate) mod snapshots;
111pub mod storage;
112pub mod storage_backend;
113pub mod streams;
114pub mod ttl;
115pub mod types;
116pub mod validation;
117#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
121pub(crate) mod dynamo_ops;
122#[cfg(any(feature = "wasm-sqlite", test))]
127pub mod wasm_api;
128#[cfg(feature = "wasm-harness")]
129pub mod wasm_harness;
130
131#[doc(hidden)]
132pub use macros::ItemInsert;
133
134use std::collections::HashMap;
135use std::sync::{Arc, Mutex};
136use web_time::{Duration, Instant};
137
138pub use errors::{DynoxideError, Result};
139pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
140pub use storage_backend::BackendError;
141#[cfg(feature = "wasm-sqlite")]
142pub use storage_backend::WasmBridgeBackend;
143
144pub use types::{AttributeValue, ConversionError, Item};
145
146#[cfg(any(feature = "http-server", feature = "mcp-server"))]
147pub use server_options::ServerOptions;
148
149pub const PRODUCT_VERSION: &str = env!("DYNOXIDE_PRODUCT_VERSION");
157
158use actions::vector_lifecycle::VectorIndexLifecycle;
159
160#[derive(Debug, Clone, Default)]
162pub struct ImportOptions {
163 pub record_streams: bool,
165 pub set_cached_at: bool,
167}
168
169#[derive(Debug, Clone)]
171pub struct ImportResult {
172 pub items_imported: usize,
174 pub bytes_imported: usize,
176}
177
178type TokenSlot<T> = (Instant, u64, Option<T>);
186
187type TokenCache<T> = HashMap<String, TokenSlot<T>>;
189
190type TransactWriteTokenCache = TokenCache<actions::transact_write_items::CachedWrite>;
194
195type ExecuteTransactionTokenCache = TokenCache<actions::execute_transaction::CachedTransaction>;
201
202#[derive(Default)]
207pub struct TokenCaches {
208 #[cfg_attr(
212 not(any(feature = "native-sqlite", feature = "_has-encryption")),
213 allow(dead_code)
214 )]
215 transact_write: Mutex<TransactWriteTokenCache>,
216 execute_transaction: Mutex<ExecuteTransactionTokenCache>,
217}
218
219impl TokenCaches {
220 pub fn new() -> Self {
222 Self::default()
223 }
224
225 #[cfg(any(feature = "wasm-sqlite", test))]
226 pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
227 &self.execute_transaction
228 }
229
230 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
239 pub(crate) fn lock_all(&self) -> TokenCacheGuards<'_> {
240 TokenCacheGuards {
241 transact_write: self
242 .transact_write
243 .lock()
244 .unwrap_or_else(|e| e.into_inner()),
245 execute_transaction: self
246 .execute_transaction
247 .lock()
248 .unwrap_or_else(|e| e.into_inner()),
249 }
250 }
251}
252
253#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
255pub(crate) struct TokenCacheGuards<'a> {
256 transact_write: std::sync::MutexGuard<'a, TransactWriteTokenCache>,
257 execute_transaction: std::sync::MutexGuard<'a, ExecuteTransactionTokenCache>,
258}
259
260#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
261impl TokenCacheGuards<'_> {
262 pub(crate) fn clear(&mut self) {
264 self.transact_write.clear();
265 self.execute_transaction.clear();
266 }
267}
268
269const MAX_TOKEN_LEN: usize = 36;
271
272const TOKEN_EXPIRY_SECS: u64 = 600;
275
276fn validate_token(token: Option<&str>) -> Result<()> {
278 match token_constraint_error(token) {
279 Some(msg) => Err(DynoxideError::ValidationException(
280 validation::envelope_message(&msg),
281 )),
282 None => Ok(()),
283 }
284}
285
286pub(crate) fn token_constraint_error(token: Option<&str>) -> Option<String> {
290 match token {
291 Some(token) if token.len() > MAX_TOKEN_LEN => Some(format!(
292 "Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
293 )),
294 _ => None,
295 }
296}
297
298fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
305 use std::hash::{Hash, Hasher};
306 let mut hasher = std::collections::hash_map::DefaultHasher::new();
307 canonical_json(input).hash(&mut hasher);
308 hasher.finish()
309}
310
311pub(crate) fn canonical_json<T: serde::Serialize + ?Sized>(value: &T) -> Vec<u8> {
320 serde_json::to_value(value)
321 .map(sorted_json)
322 .and_then(|v| serde_json::to_vec(&v))
323 .unwrap_or_default()
324}
325
326pub(crate) fn sorted_json(value: serde_json::Value) -> serde_json::Value {
330 match value {
331 serde_json::Value::Object(map) => {
332 let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
333 entries.sort_by(|a, b| a.0.cmp(&b.0));
334 serde_json::Value::Object(
335 entries
336 .into_iter()
337 .map(|(k, v)| (k, sorted_json(v)))
338 .collect(),
339 )
340 }
341 serde_json::Value::Array(items) => {
342 serde_json::Value::Array(items.into_iter().map(sorted_json).collect())
343 }
344 other => other,
345 }
346}
347
348fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
349 cache
350 .lock()
351 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
352}
353
354fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
360 cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
361}
362
363fn token_window() -> Duration {
365 Duration::from_secs(TOKEN_EXPIRY_SECS)
366}
367
368#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
389fn run_idempotent<T, H, E, R>(
390 cache: &Mutex<TokenCache<T>>,
391 token: Option<&str>,
392 hash_input: &H,
393 execute: E,
394 replay: R,
395) -> Result<T>
396where
397 T: Clone,
398 H: serde::Serialize,
399 E: FnOnce() -> Result<T>,
400 R: FnOnce(&T) -> T,
401{
402 validate_token(token)?;
403
404 let Some(token) = token else {
406 return execute();
407 };
408 let hash = request_hash(hash_input);
409
410 let mut cache = lock_cache(cache)?;
411 evict_expired(&mut cache, token_window());
412 let cached = match cache.get(token) {
413 Some((_, cached_hash, _)) if *cached_hash != hash => {
414 return Err(DynoxideError::IdempotentParameterMismatchException(
415 "An error occurred (IdempotentParameterMismatchException)".to_string(),
416 ));
417 }
418 Some((_, _, slot)) => slot.clone(),
422 None => None,
423 };
424 if let Some(cached) = cached {
425 drop(cache);
427 return Ok(replay(&cached));
428 }
429 let resp = execute()?;
433 cache.insert(
434 token.to_string(),
435 (Instant::now(), hash, Some(resp.clone())),
436 );
437 Ok(resp)
438}
439
440#[cfg(any(feature = "wasm-sqlite", test))]
443const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
444
445#[cfg(any(feature = "wasm-sqlite", test))]
447enum TokenClaim<T> {
448 Marked(Instant),
452 InFlight,
454 Mismatch,
456 Hit(T),
458}
459
460#[cfg(any(feature = "wasm-sqlite", test))]
466fn lookup_or_claim<T: Clone>(
467 cache: &Mutex<TokenCache<T>>,
468 token: &str,
469 hash: u64,
470) -> Result<TokenClaim<T>> {
471 lookup_or_claim_within(cache, token, hash, token_window())
472}
473
474#[cfg(any(feature = "wasm-sqlite", test))]
477fn lookup_or_claim_within<T: Clone>(
478 cache: &Mutex<TokenCache<T>>,
479 token: &str,
480 hash: u64,
481 window: Duration,
482) -> Result<TokenClaim<T>> {
483 let mut cache = lock_cache(cache)?;
484 evict_expired(&mut cache, window);
485 Ok(match cache.get(token) {
486 Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
487 Some((_, _, None)) => TokenClaim::InFlight,
488 Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
489 None => {
490 let claimed_at = Instant::now();
491 cache.insert(token.to_string(), (claimed_at, hash, None));
492 TokenClaim::Marked(claimed_at)
493 }
494 })
495}
496
497#[cfg(any(feature = "wasm-sqlite", test))]
504fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
505 matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
506}
507
508#[cfg(any(feature = "wasm-sqlite", test))]
510fn record_complete<T>(
511 cache: &Mutex<TokenCache<T>>,
512 token: &str,
513 claimed_at: Instant,
514 hash: u64,
515 resp: T,
516) -> Result<()> {
517 let mut cache = lock_cache(cache)?;
518 if still_ours(&cache, token, claimed_at) {
519 cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
520 }
521 Ok(())
522}
523
524#[cfg(any(feature = "wasm-sqlite", test))]
527fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
528 let mut cache = lock_cache(cache)?;
529 if still_ours(&cache, token, claimed_at) {
530 cache.remove(token);
531 }
532 Ok(())
533}
534
535#[cfg(any(feature = "wasm-sqlite", test))]
548async fn run_idempotent_async<T, H, F, R>(
549 cache: &Mutex<TokenCache<T>>,
550 token: Option<&str>,
551 hash_input: &H,
552 execute: F,
553 replay: R,
554) -> Result<T>
555where
556 T: Clone,
557 H: serde::Serialize,
558 F: std::future::Future<Output = Result<T>>,
559 R: FnOnce(&T) -> T,
560{
561 validate_token(token)?;
562
563 let Some(token) = token else {
564 return execute.await;
565 };
566 let hash = request_hash(hash_input);
567
568 let claimed_at = match lookup_or_claim(cache, token, hash)? {
569 TokenClaim::Hit(cached) => return Ok(replay(&cached)),
570 TokenClaim::Mismatch => {
571 return Err(DynoxideError::IdempotentParameterMismatchException(
572 "An error occurred (IdempotentParameterMismatchException)".to_string(),
573 ));
574 }
575 TokenClaim::InFlight => {
576 return Err(DynoxideError::InternalServerError(
577 IN_FLIGHT_MESSAGE.to_string(),
578 ));
579 }
580 TokenClaim::Marked(claimed_at) => claimed_at,
581 };
582
583 match execute.await {
584 Ok(resp) => {
585 let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
589 Ok(resp)
590 }
591 Err(e) => {
592 let _ = clear_claim(cache, token, claimed_at);
593 Err(e)
594 }
595 }
596}
597
598#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
603pub type RusqliteBackend = storage::Storage;
604
605#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
613pub type NativeDatabase = Database<RusqliteBackend>;
614
615#[cfg(feature = "wasm-sqlite")]
622pub type WasmDatabase = Database<WasmBridgeBackend>;
623
624#[cfg(feature = "wasm-sqlite")]
636pub const WASM_BACKEND: bool = true;
637#[cfg(not(feature = "wasm-sqlite"))]
640pub const WASM_BACKEND: bool = false;
641
642#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
653pub struct Database<S = RusqliteBackend> {
654 inner: Arc<handoff_mutex::HandoffMutex<S>>,
655 tokens: Arc<TokenCaches>,
656 vector_lifecycle: Arc<VectorIndexLifecycle>,
657}
658
659#[cfg(all(
665 not(any(feature = "native-sqlite", feature = "_has-encryption")),
666 feature = "wasm-sqlite"
667))]
668use async_lock::Mutex as BackendMutex;
669#[cfg(all(
670 not(any(feature = "native-sqlite", feature = "_has-encryption")),
671 not(feature = "wasm-sqlite")
672))]
673use std::sync::Mutex as BackendMutex;
674
675#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
685pub struct Database<S> {
686 inner: Arc<BackendMutex<S>>,
687 tokens: Arc<TokenCaches>,
688 vector_lifecycle: Arc<VectorIndexLifecycle>,
689}
690
691impl<S> Clone for Database<S> {
693 fn clone(&self) -> Self {
694 Self {
695 inner: Arc::clone(&self.inner),
696 tokens: Arc::clone(&self.tokens),
697 vector_lifecycle: Arc::clone(&self.vector_lifecycle),
698 }
699 }
700}
701
702#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
703impl Database<RusqliteBackend> {
704 pub fn new(path: &str) -> Result<Self> {
706 let storage = storage::Storage::new(path)?;
707 Ok(Self {
708 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
709 tokens: Arc::new(TokenCaches::new()),
710 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
711 })
712 }
713
714 #[cfg(feature = "_has-encryption")]
735 pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
736 if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
737 return Err(DynoxideError::ValidationException(
738 "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
739 ));
740 }
741
742 let storage = storage::Storage::new_encrypted(path, key)?;
743 Ok(Self {
744 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
745 tokens: Arc::new(TokenCaches::new()),
746 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
747 })
748 }
749
750 pub fn memory() -> Result<Self> {
752 let storage = storage::Storage::memory()?;
753 Ok(Self {
754 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
755 tokens: Arc::new(TokenCaches::new()),
756 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
757 })
758 }
759
760 pub fn memory_with_clock(clock: Arc<dyn storage_backend::Clock>) -> Result<Self> {
767 let storage = storage::Storage::memory()?.with_clock(clock);
768 Ok(Self {
769 inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
770 tokens: Arc::new(TokenCaches::new()),
771 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
772 })
773 }
774
775 pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
777 where
778 F: FnOnce(&storage::Storage) -> Result<T>,
779 {
780 let guard = self
781 .inner
782 .lock()
783 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
784 f(&guard)
785 }
786
787 pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
789 where
790 F: FnOnce(&mut storage::Storage) -> Result<T>,
791 {
792 let mut guard = self
793 .inner
794 .lock()
795 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
796 f(&mut guard)
797 }
798
799 pub fn create_table(
805 &self,
806 request: actions::create_table::CreateTableRequest,
807 ) -> Result<actions::create_table::CreateTableResponse> {
808 self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
809 }
810
811 pub fn delete_table(
813 &self,
814 request: actions::delete_table::DeleteTableRequest,
815 ) -> Result<actions::delete_table::DeleteTableResponse> {
816 self.with_storage(|s| {
817 pollster::block_on(actions::delete_table::execute(
818 s,
819 request,
820 &self.vector_lifecycle,
821 ))
822 })
823 }
824
825 pub fn describe_table(
827 &self,
828 request: actions::describe_table::DescribeTableRequest,
829 ) -> Result<actions::describe_table::DescribeTableResponse> {
830 self.with_storage(|s| {
831 pollster::block_on(actions::describe_table::execute(
832 s,
833 request,
834 &self.vector_lifecycle,
835 ))
836 })
837 }
838
839 pub fn update_table(
841 &self,
842 request: actions::update_table::UpdateTableRequest,
843 ) -> Result<actions::update_table::UpdateTableResponse> {
844 self.with_storage(|s| {
845 pollster::block_on(actions::update_table::execute(
846 s,
847 request,
848 &self.vector_lifecycle,
849 ))
850 })
851 }
852
853 pub fn list_tables(
855 &self,
856 request: actions::list_tables::ListTablesRequest,
857 ) -> Result<actions::list_tables::ListTablesResponse> {
858 self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
859 }
860
861 pub fn tag_resource(
867 &self,
868 request: actions::tag_resource::TagResourceRequest,
869 ) -> Result<actions::tag_resource::TagResourceResponse> {
870 self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
871 }
872
873 pub fn untag_resource(
875 &self,
876 request: actions::untag_resource::UntagResourceRequest,
877 ) -> Result<actions::untag_resource::UntagResourceResponse> {
878 self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
879 }
880
881 pub fn list_tags_of_resource(
883 &self,
884 request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
885 ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
886 self.with_storage(|s| {
887 pollster::block_on(actions::list_tags_of_resource::execute(s, request))
888 })
889 }
890
891 pub fn put_item(
897 &self,
898 request: actions::put_item::PutItemRequest,
899 ) -> Result<actions::put_item::PutItemResponse> {
900 self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
901 }
902
903 pub fn get_item(
905 &self,
906 request: actions::get_item::GetItemRequest,
907 ) -> Result<actions::get_item::GetItemResponse> {
908 self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
909 }
910
911 pub fn delete_item(
913 &self,
914 request: actions::delete_item::DeleteItemRequest,
915 ) -> Result<actions::delete_item::DeleteItemResponse> {
916 self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
917 }
918
919 pub fn update_item(
921 &self,
922 request: actions::update_item::UpdateItemRequest,
923 ) -> Result<actions::update_item::UpdateItemResponse> {
924 self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
925 }
926
927 pub fn batch_get_item(
933 &self,
934 request: actions::batch_get_item::BatchGetItemRequest,
935 ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
936 self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
937 }
938
939 pub fn batch_write_item(
941 &self,
942 request: actions::batch_write_item::BatchWriteItemRequest,
943 ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
944 self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
945 }
946
947 pub fn import_items(
962 &self,
963 table_name: &str,
964 items: Vec<Item>,
965 options: ImportOptions,
966 ) -> Result<ImportResult> {
967 self.with_storage(|s| {
968 pollster::block_on(actions::import_items::execute(
969 s, table_name, items, &options,
970 ))
971 })
972 }
973
974 #[cfg(feature = "import")]
980 pub(crate) fn import_items_fresh(
981 &self,
982 table_name: &str,
983 items: Vec<Item>,
984 options: ImportOptions,
985 ) -> Result<ImportResult> {
986 self.with_storage(|s| {
987 pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
988 s, table_name, items, &options,
989 ))
990 })
991 }
992
993 pub fn enable_bulk_loading(&self) -> Result<()> {
1002 self.with_storage(|s| s.enable_bulk_loading())
1003 }
1004
1005 pub fn disable_bulk_loading(&self) -> Result<()> {
1007 self.with_storage(|s| s.disable_bulk_loading())
1008 }
1009
1010 pub fn query(
1016 &self,
1017 request: actions::query::QueryRequest,
1018 ) -> Result<actions::query::QueryResponse> {
1019 self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
1020 }
1021
1022 pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
1024 self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
1025 }
1026
1027 pub fn search_vectors(
1029 &self,
1030 request: actions::search_vectors::SearchVectorsRequest,
1031 ) -> Result<actions::search_vectors::SearchVectorsResponse> {
1032 self.with_storage(|s| {
1033 pollster::block_on(actions::search_vectors::execute(
1034 s,
1035 request,
1036 &self.vector_lifecycle,
1037 ))
1038 })
1039 }
1040
1041 pub fn transact_write_items(
1052 &self,
1053 request: actions::transact_write_items::TransactWriteItemsRequest,
1054 ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
1055 actions::transact_write_items::check_request(&request)?;
1059 run_idempotent(
1060 &self.tokens.transact_write,
1061 request.client_request_token.as_deref(),
1062 &request.transact_items,
1063 || {
1064 self.with_storage(|s| {
1065 pollster::block_on(actions::transact_write_items::execute_cached(
1066 s,
1067 request.clone(),
1068 ))
1069 })
1070 },
1071 |cached| {
1072 actions::transact_write_items::replay_response(
1077 cached,
1078 &request.return_consumed_capacity,
1079 )
1080 },
1081 )
1082 .map(|cached| cached.response)
1083 }
1084
1085 pub fn transact_get_items(
1087 &self,
1088 request: actions::transact_get_items::TransactGetItemsRequest,
1089 ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
1090 self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
1091 }
1092
1093 pub fn list_streams(
1099 &self,
1100 request: actions::list_streams::ListStreamsRequest,
1101 ) -> Result<actions::list_streams::ListStreamsResponse> {
1102 self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
1103 }
1104
1105 pub fn describe_stream(
1107 &self,
1108 request: actions::describe_stream::DescribeStreamRequest,
1109 ) -> Result<actions::describe_stream::DescribeStreamResponse> {
1110 self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
1111 }
1112
1113 pub fn get_shard_iterator(
1115 &self,
1116 request: actions::get_shard_iterator::GetShardIteratorRequest,
1117 ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
1118 self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
1119 }
1120
1121 pub fn get_records(
1123 &self,
1124 request: actions::get_records::GetRecordsRequest,
1125 ) -> Result<actions::get_records::GetRecordsResponse> {
1126 self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
1127 }
1128
1129 pub fn update_time_to_live(
1135 &self,
1136 request: actions::update_time_to_live::UpdateTimeToLiveRequest,
1137 ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
1138 self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
1139 }
1140
1141 pub fn describe_time_to_live(
1143 &self,
1144 request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
1145 ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
1146 self.with_storage(|s| {
1147 pollster::block_on(actions::describe_time_to_live::execute(s, request))
1148 })
1149 }
1150
1151 pub fn sweep_ttl(&self) -> Result<usize> {
1166 self.sweep_ttl_by_page(|_| {}, |_| {})
1167 }
1168
1169 fn sweep_ttl_by_page(
1174 &self,
1175 mut in_page: impl FnMut(usize),
1176 mut between_pages: impl FnMut(usize),
1177 ) -> Result<usize> {
1178 let mut sweep = self.with_storage(|s| pollster::block_on(ttl::Sweep::begin(s)))?;
1179 let mut page = 0;
1180 loop {
1181 page += 1;
1182 let hand_off = self.with_storage(|s| {
1185 let more = pollster::block_on(sweep.next_page(s))?;
1186 in_page(page);
1187 Ok(more.then(|| self.inner.start_hand_off()))
1188 })?;
1189 let Some(hand_off) = hand_off else {
1190 return Ok(sweep.deleted());
1191 };
1192 hand_off.wait();
1193 between_pages(page);
1194 }
1195 }
1196
1197 pub fn execute_statement(
1203 &self,
1204 request: actions::execute_statement::ExecuteStatementRequest,
1205 ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
1206 self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
1207 }
1208
1209 pub fn execute_transaction(
1217 &self,
1218 request: actions::execute_transaction::ExecuteTransactionRequest,
1219 ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
1220 actions::execute_transaction::check_members(&request)?;
1225 run_idempotent(
1226 &self.tokens.execute_transaction,
1227 request.client_request_token.as_deref(),
1228 &actions::execute_transaction::token_statements(&request.transact_statements),
1229 || {
1230 self.with_storage(|s| {
1231 pollster::block_on(actions::execute_transaction::execute_cached(
1232 s,
1233 request.clone(),
1234 ))
1235 })
1236 },
1237 |cached| {
1238 actions::execute_transaction::replay_response(
1241 cached,
1242 &request.return_consumed_capacity,
1243 )
1244 },
1245 )
1246 .map(|cached| cached.response)
1247 }
1248
1249 pub fn batch_execute_statement(
1251 &self,
1252 request: actions::batch_execute_statement::BatchExecuteStatementRequest,
1253 ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
1254 self.with_storage(|s| {
1255 pollster::block_on(actions::batch_execute_statement::execute(s, request))
1256 })
1257 }
1258
1259 pub fn touch_cached_at(
1268 &self,
1269 table_name: &str,
1270 pk: &str,
1271 sk: &str,
1272 timestamp: f64,
1273 ) -> Result<()> {
1274 self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
1275 }
1276
1277 pub fn get_lru_items(
1282 &self,
1283 table_name: &str,
1284 limit: usize,
1285 ) -> Result<Vec<(String, String, i64)>> {
1286 self.with_storage(|s| s.get_lru_items(table_name, limit))
1287 }
1288
1289 pub fn db_path(&self) -> Result<Option<String>> {
1295 self.with_storage(|s| Ok(s.db_path()))
1296 }
1297
1298 pub fn db_size_bytes(&self) -> Result<u64> {
1300 self.with_storage(|s| s.db_size_bytes())
1301 }
1302
1303 pub fn table_count(&self) -> Result<usize> {
1305 self.with_storage(|s| s.table_count())
1306 }
1307
1308 pub fn table_stats(&self) -> Result<Vec<TableStats>> {
1310 self.with_storage(|s| s.table_stats())
1311 }
1312
1313 pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
1315 self.with_storage(|s| s.get_table_metadata(table_name))
1316 }
1317
1318 pub fn database_info(&self) -> Result<DatabaseInfo> {
1323 self.with_storage(|s| s.database_info())
1324 }
1325
1326 pub fn vacuum(&self) -> Result<()> {
1332 self.with_storage(|s| s.vacuum())
1333 }
1334
1335 pub fn vacuum_into(&self, path: &str) -> Result<()> {
1340 self.with_storage(|s| s.vacuum_into(path))
1341 }
1342
1343 pub fn restore_from(&self, path: &str) -> Result<()> {
1356 self.restore_with(|s| s.restore_from(path))
1357 }
1358
1359 fn restore_with(
1368 &self,
1369 restore: impl FnOnce(&mut storage::Storage) -> Result<()>,
1370 ) -> Result<()> {
1371 let mut tokens = self.tokens.lock_all();
1372 self.with_storage_mut(|s| {
1373 restore(s)?;
1374 tokens.clear();
1375 self.vector_lifecycle.forget_all();
1376 Ok(())
1377 })
1378 }
1379
1380 #[cfg(feature = "mcp-server")]
1385 pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
1386 self.with_storage(|s| s.backup_to_memory())
1387 }
1388
1389 #[cfg(feature = "mcp-server")]
1395 pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
1396 self.restore_with(|s| s.restore_from_connection(source))
1397 }
1398}
1399
1400#[cfg(feature = "wasm-sqlite")]
1414impl Database<WasmBridgeBackend> {
1415 pub async fn open(name: &str) -> Result<Self> {
1418 Self::open_with(name, false).await
1419 }
1420
1421 pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
1424 let backend = WasmBridgeBackend::open_with(name, ephemeral)
1425 .await
1426 .map_err(DynoxideError::from)?;
1427 Ok(Self {
1428 inner: Arc::new(BackendMutex::new(backend)),
1429 tokens: Arc::new(TokenCaches::new()),
1430 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
1431 })
1432 }
1433
1434 pub async fn persistence_mode(&self) -> String {
1436 self.backend().await.persistence_mode().to_string()
1437 }
1438
1439 pub async fn close(&self) -> Result<()> {
1443 self.backend()
1444 .await
1445 .close()
1446 .await
1447 .map_err(DynoxideError::from)
1448 }
1449
1450 pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
1459 self.inner.lock().await
1460 }
1461
1462 pub(crate) fn token_caches(&self) -> &TokenCaches {
1466 &self.tokens
1467 }
1468
1469 pub(crate) fn vector_lifecycle(&self) -> &VectorIndexLifecycle {
1473 &self.vector_lifecycle
1474 }
1475
1476 pub async fn create_table(
1478 &self,
1479 request: actions::create_table::CreateTableRequest,
1480 ) -> Result<actions::create_table::CreateTableResponse> {
1481 let backend = self.backend().await;
1482 actions::create_table::execute(&*backend, request).await
1483 }
1484
1485 pub async fn delete_table(
1487 &self,
1488 request: actions::delete_table::DeleteTableRequest,
1489 ) -> Result<actions::delete_table::DeleteTableResponse> {
1490 let backend = self.backend().await;
1491 actions::delete_table::execute(&*backend, request, &self.vector_lifecycle).await
1492 }
1493
1494 pub async fn describe_table(
1496 &self,
1497 request: actions::describe_table::DescribeTableRequest,
1498 ) -> Result<actions::describe_table::DescribeTableResponse> {
1499 let backend = self.backend().await;
1500 actions::describe_table::execute(&*backend, request, &self.vector_lifecycle).await
1501 }
1502
1503 pub async fn list_tables(
1505 &self,
1506 request: actions::list_tables::ListTablesRequest,
1507 ) -> Result<actions::list_tables::ListTablesResponse> {
1508 let backend = self.backend().await;
1509 actions::list_tables::execute(&*backend, request).await
1510 }
1511
1512 pub async fn put_item(
1514 &self,
1515 request: actions::put_item::PutItemRequest,
1516 ) -> Result<actions::put_item::PutItemResponse> {
1517 let backend = self.backend().await;
1518 actions::put_item::execute(&*backend, request).await
1519 }
1520
1521 pub async fn get_item(
1523 &self,
1524 request: actions::get_item::GetItemRequest,
1525 ) -> Result<actions::get_item::GetItemResponse> {
1526 let backend = self.backend().await;
1527 actions::get_item::execute(&*backend, request).await
1528 }
1529
1530 pub async fn delete_item(
1532 &self,
1533 request: actions::delete_item::DeleteItemRequest,
1534 ) -> Result<actions::delete_item::DeleteItemResponse> {
1535 let backend = self.backend().await;
1536 actions::delete_item::execute(&*backend, request).await
1537 }
1538
1539 pub async fn query(
1541 &self,
1542 request: actions::query::QueryRequest,
1543 ) -> Result<actions::query::QueryResponse> {
1544 let backend = self.backend().await;
1545 actions::query::execute(&*backend, request).await
1546 }
1547
1548 pub async fn scan(
1550 &self,
1551 request: actions::scan::ScanRequest,
1552 ) -> Result<actions::scan::ScanResponse> {
1553 let backend = self.backend().await;
1554 actions::scan::execute(&*backend, request).await
1555 }
1556
1557 pub async fn search_vectors(
1561 &self,
1562 request: actions::search_vectors::SearchVectorsRequest,
1563 ) -> Result<actions::search_vectors::SearchVectorsResponse> {
1564 let backend = self.backend().await;
1565 actions::search_vectors::execute(&*backend, request, &self.vector_lifecycle).await
1566 }
1567}
1568
1569#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1570mod tests {
1571 use super::*;
1572
1573 fn database_with_snapshot(dir: &tempfile::TempDir) -> (Database, String) {
1575 let snapshot = dir.path().join("before.db");
1576 let snapshot = snapshot.to_str().unwrap().to_string();
1577 let db = Database::memory().unwrap();
1578 db.create_table(
1579 serde_json::from_value(serde_json::json!({
1580 "TableName": "tbl",
1581 "BillingMode": "PAY_PER_REQUEST",
1582 "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1583 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1584 }))
1585 .unwrap(),
1586 )
1587 .unwrap();
1588 db.vacuum_into(&snapshot).unwrap();
1589 (db, snapshot)
1590 }
1591
1592 fn assert_restore_keeps_cache_then_storage_order<T: Send + 'static>(
1599 db: &Database,
1600 cache: &Mutex<TokenCache<T>>,
1601 restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
1602 ) {
1603 let held = cache.lock().unwrap();
1604 let (ready, is_ready) = std::sync::mpsc::channel();
1605 let restorer = {
1606 let db = db.clone();
1607 std::thread::spawn(move || {
1608 let _ = ready.send(());
1609 restore(db)
1610 })
1611 };
1612 is_ready.recv().unwrap();
1613 let (sent, got) = std::sync::mpsc::channel();
1614 {
1615 let db = db.clone();
1616 std::thread::spawn(move || {
1617 let _ = sent.send(db.with_storage(|_| Ok(())).is_ok());
1618 });
1619 }
1620 let storage_step = got.recv_timeout(std::time::Duration::from_secs(5));
1621 let restore_waited = !restorer.is_finished();
1622 drop(held);
1623 assert!(
1624 matches!(storage_step, Ok(true)),
1625 "a transaction holding its token cache must still get the storage lock while a restore waits"
1626 );
1627 assert!(
1628 restore_waited,
1629 "the restore must wait for the token cache the transaction holds"
1630 );
1631 restorer
1632 .join()
1633 .unwrap()
1634 .expect("the restore finishes once the cache is free");
1635 }
1636
1637 fn assert_restore_takes_cache_before_storage<T: Send + 'static>(
1642 db: &Database,
1643 cache: &Mutex<TokenCache<T>>,
1644 restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
1645 ) {
1646 let storage = db.inner.lock().unwrap();
1647 let restorer = {
1648 let db = db.clone();
1649 std::thread::spawn(move || restore(db))
1650 };
1651 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1652 while db.inner.waiting() == 0 {
1653 assert!(
1654 std::time::Instant::now() < deadline,
1655 "the restore never asked for the storage lock"
1656 );
1657 std::thread::yield_now();
1658 }
1659 let cache_was_free = cache.try_lock().is_ok();
1660 drop(storage);
1661 restorer
1662 .join()
1663 .unwrap()
1664 .expect("the restore finishes once storage is free");
1665 assert!(
1666 !cache_was_free,
1667 "a restore must hold the token caches before it waits for storage"
1668 );
1669 }
1670
1671 #[test]
1672 fn a_restore_cannot_deadlock_against_a_tokened_transaction() {
1673 let dir = tempfile::tempdir().unwrap();
1674 let (db, snapshot) = database_with_snapshot(&dir);
1675 let restore = |path: &String| {
1676 let path = path.clone();
1677 move |db: Database| db.restore_from(&path)
1678 };
1679 assert_restore_keeps_cache_then_storage_order(
1680 &db,
1681 &db.tokens.transact_write,
1682 restore(&snapshot),
1683 );
1684 assert_restore_keeps_cache_then_storage_order(
1685 &db,
1686 &db.tokens.execute_transaction,
1687 restore(&snapshot),
1688 );
1689 assert_restore_takes_cache_before_storage(
1690 &db,
1691 &db.tokens.transact_write,
1692 restore(&snapshot),
1693 );
1694 assert_restore_takes_cache_before_storage(
1695 &db,
1696 &db.tokens.execute_transaction,
1697 restore(&snapshot),
1698 );
1699 }
1700
1701 #[cfg(feature = "mcp-server")]
1704 fn connection_restore(db: &Database) -> impl FnOnce(Database) -> Result<()> + Send + 'static {
1705 let copy = db.backup_to_memory().unwrap();
1706 move |db: Database| db.restore_from_connection(©)
1707 }
1708
1709 #[cfg(feature = "mcp-server")]
1710 #[test]
1711 fn a_connection_restore_cannot_deadlock_against_a_tokened_transaction() {
1712 let dir = tempfile::tempdir().unwrap();
1713 let (db, _) = database_with_snapshot(&dir);
1714 assert_restore_keeps_cache_then_storage_order(
1715 &db,
1716 &db.tokens.transact_write,
1717 connection_restore(&db),
1718 );
1719 assert_restore_keeps_cache_then_storage_order(
1720 &db,
1721 &db.tokens.execute_transaction,
1722 connection_restore(&db),
1723 );
1724 assert_restore_takes_cache_before_storage(
1725 &db,
1726 &db.tokens.transact_write,
1727 connection_restore(&db),
1728 );
1729 assert_restore_takes_cache_before_storage(
1730 &db,
1731 &db.tokens.execute_transaction,
1732 connection_restore(&db),
1733 );
1734 }
1735
1736 #[test]
1737 fn a_restore_forgets_cached_transactions_and_vector_windows() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let snapshot = dir.path().join("before.db");
1740 let snapshot = snapshot.to_str().unwrap();
1741 let db = Database::memory().unwrap();
1742 db.create_table(
1743 serde_json::from_value(serde_json::json!({
1744 "TableName": "tbl",
1745 "BillingMode": "PAY_PER_REQUEST",
1746 "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1747 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1748 }))
1749 .unwrap(),
1750 )
1751 .unwrap();
1752 db.vacuum_into(snapshot).unwrap();
1753
1754 let put = || {
1755 serde_json::from_value::<actions::transact_write_items::TransactWriteItemsRequest>(
1756 serde_json::json!({
1757 "ClientRequestToken": "same-token",
1758 "TransactItems": [{"Put": {"TableName": "tbl", "Item": {"pk": {"S": "a"}}}}]
1759 }),
1760 )
1761 .unwrap()
1762 };
1763 db.transact_write_items(put()).unwrap();
1764 db.with_storage(|s| {
1765 db.vector_lifecycle.arm(s, "tbl", "v");
1766 Ok(())
1767 })
1768 .unwrap();
1769 assert!(!db.vector_lifecycle.is_empty());
1770
1771 db.restore_from(snapshot).unwrap();
1772 assert!(
1773 db.vector_lifecycle.is_empty(),
1774 "the restored tables' creation windows must not survive"
1775 );
1776
1777 db.transact_write_items(put()).unwrap();
1780 let got = db
1781 .get_item(
1782 serde_json::from_value(serde_json::json!({
1783 "TableName": "tbl",
1784 "Key": {"pk": {"S": "a"}}
1785 }))
1786 .unwrap(),
1787 )
1788 .unwrap();
1789 assert!(
1790 got.item.is_some(),
1791 "the retried transaction must have written"
1792 );
1793 }
1794
1795 #[test]
1796 fn an_unrelated_update_leaves_an_unsorted_stored_set_under_an_index_key_alone() {
1797 let db = Database::memory().unwrap();
1798 db.create_table(
1799 serde_json::from_value(serde_json::json!({
1800 "TableName": "tbl",
1801 "BillingMode": "PAY_PER_REQUEST",
1802 "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1803 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1804 }))
1805 .unwrap(),
1806 )
1807 .unwrap();
1808 db.put_item(
1809 serde_json::from_value(serde_json::json!({
1810 "TableName": "tbl",
1811 "Item": {"pk": {"S": "k"}, "g": {"SS": ["a", "b"]}}
1812 }))
1813 .unwrap(),
1814 )
1815 .unwrap();
1816 let stored: String = db
1818 .with_storage(|s| {
1819 s.conn().execute(
1820 "UPDATE \"tbl\" SET item_json = replace(item_json, '\"SS\":[\"a\",\"b\"]', '\"SS\":[\"b\",\"a\"]') \
1821 WHERE item_json LIKE '%\"SS\":[\"a\",\"b\"]%'",
1822 [],
1823 )?;
1824 Ok(s.conn()
1825 .query_row("SELECT item_json FROM \"tbl\"", [], |row| row.get(0))?)
1826 })
1827 .unwrap();
1828 assert!(
1829 stored.contains(r#""SS":["b","a"]"#),
1830 "the fixture must hold an unsorted set: {stored}"
1831 );
1832 db.update_table(
1835 serde_json::from_value(serde_json::json!({
1836 "TableName": "tbl",
1837 "AttributeDefinitions": [{"AttributeName": "g", "AttributeType": "S"}],
1838 "GlobalSecondaryIndexUpdates": [{"Create": {
1839 "IndexName": "byG",
1840 "KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}],
1841 "Projection": {"ProjectionType": "ALL"}
1842 }}]
1843 }))
1844 .unwrap(),
1845 )
1846 .unwrap();
1847
1848 db.update_item(
1849 serde_json::from_value(serde_json::json!({
1850 "TableName": "tbl",
1851 "Key": {"pk": {"S": "k"}},
1852 "UpdateExpression": "SET note = :v",
1853 "ExpressionAttributeValues": {":v": {"S": "x"}}
1854 }))
1855 .unwrap(),
1856 )
1857 .expect("an update that doesn't touch g must not re-check g against the index");
1858 }
1859
1860 #[test]
1861 fn test_database_memory() {
1862 let db = Database::memory().unwrap();
1863 let _db2 = db.clone();
1865 }
1866
1867 #[test]
1868 fn test_database_with_storage() {
1869 let db = Database::memory().unwrap();
1870 let tables = db.with_storage(|s| s.list_table_names()).unwrap();
1871 assert!(tables.is_empty());
1872 }
1873
1874 #[test]
1875 fn test_database_thread_safe() {
1876 let db = Database::memory().unwrap();
1877 let db2 = db.clone();
1878
1879 let handle =
1880 std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
1881
1882 let tables = handle.join().unwrap();
1883 assert!(tables.is_empty());
1884 }
1885
1886 #[test]
1887 fn test_native_database_alias_round_trips() {
1888 let db: NativeDatabase = Database::memory().unwrap();
1892
1893 db.create_table(actions::create_table::CreateTableRequest {
1894 table_name: "tbl".to_string(),
1895 key_schema: vec![types::KeySchemaElement {
1896 attribute_name: "pk".to_string(),
1897 key_type: types::KeyType::HASH,
1898 }],
1899 attribute_definitions: vec![types::AttributeDefinition {
1900 attribute_name: "pk".to_string(),
1901 attribute_type: types::ScalarAttributeType::S,
1902 }],
1903 ..Default::default()
1904 })
1905 .unwrap();
1906
1907 let mut item = HashMap::new();
1908 item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1909 db.put_item(actions::put_item::PutItemRequest {
1910 table_name: "tbl".to_string(),
1911 item,
1912 ..Default::default()
1913 })
1914 .unwrap();
1915
1916 let mut key = HashMap::new();
1917 key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1918 let got = db
1919 .get_item(actions::get_item::GetItemRequest {
1920 table_name: "tbl".to_string(),
1921 key,
1922 ..Default::default()
1923 })
1924 .unwrap();
1925 assert_eq!(
1926 got.item.unwrap().get("pk"),
1927 Some(&AttributeValue::S("a".to_string()))
1928 );
1929 }
1930}
1931
1932#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1935mod ttl_sweep_tests;
1936
1937#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1940mod transaction_storage_error_tests;
1941
1942#[cfg(test)]
1948mod idempotency_tests {
1949 use super::*;
1950 use std::cell::Cell;
1951
1952 const KEY: &str = "statements";
1953 const TOKEN: &str = "tok";
1954
1955 fn cache() -> Mutex<TokenCache<u32>> {
1956 Mutex::new(HashMap::new())
1957 }
1958
1959 fn hash() -> u64 {
1960 request_hash(&KEY)
1961 }
1962
1963 fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
1964 where
1965 F: std::future::Future<Output = Result<u32>>,
1966 {
1967 pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
1968 }
1969
1970 #[test]
1971 fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
1972 let cache = cache();
1973
1974 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1975 panic!("the first caller should have claimed the token");
1976 };
1977 assert!(matches!(
1978 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1979 TokenClaim::InFlight
1980 ));
1981
1982 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1983 assert!(matches!(
1984 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1985 TokenClaim::Hit(7)
1986 ));
1987 }
1988
1989 #[test]
1990 fn only_one_caller_can_claim_a_token() {
1991 use std::sync::Barrier;
1995
1996 const N: usize = 16;
1997 let cache = cache();
1998 let barrier = Barrier::new(N);
1999
2000 let marked = std::thread::scope(|scope| {
2001 let handles: Vec<_> = (0..N)
2002 .map(|_| {
2003 scope.spawn(|| {
2004 barrier.wait();
2005 matches!(
2006 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2007 TokenClaim::Marked(_)
2008 )
2009 })
2010 })
2011 .collect();
2012 handles
2013 .into_iter()
2014 .map(|h| h.join().unwrap())
2015 .filter(|claimed| *claimed)
2016 .count()
2017 });
2018
2019 assert_eq!(marked, 1);
2020 }
2021
2022 #[test]
2023 fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
2024 let cache = cache();
2028 let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2029 panic!("the first caller should have claimed the token");
2030 };
2031 let TokenClaim::Marked(second) =
2032 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
2033 else {
2034 panic!("the expired claim should have been re-issued");
2035 };
2036 assert_ne!(first, second);
2037
2038 record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
2040 assert!(matches!(
2041 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2042 TokenClaim::InFlight
2043 ));
2044
2045 clear_claim(&cache, TOKEN, first).unwrap();
2047 assert!(matches!(
2048 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2049 TokenClaim::InFlight
2050 ));
2051
2052 record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
2054 assert!(matches!(
2055 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2056 TokenClaim::Hit(2)
2057 ));
2058 }
2059
2060 #[test]
2067 fn a_request_hash_ignores_map_key_order() {
2068 use crate::types::AttributeValue;
2069 let item = |n: &str| -> HashMap<String, AttributeValue> {
2070 let inner: HashMap<String, AttributeValue> = (0..16)
2071 .map(|i| (format!("i{i}"), AttributeValue::N(n.to_string())))
2072 .collect();
2073 (0..16)
2074 .map(|i| (format!("k{i}"), AttributeValue::M(inner.clone())))
2075 .collect()
2076 };
2077 for _ in 0..8 {
2078 assert_eq!(
2079 request_hash(&vec![item("1")]),
2080 request_hash(&vec![item("1")])
2081 );
2082 }
2083 assert_ne!(
2084 request_hash(&vec![item("1")]),
2085 request_hash(&vec![item("2")])
2086 );
2087 }
2088
2089 #[test]
2090 fn a_different_request_under_the_same_token_mismatches_in_both_states() {
2091 let other = request_hash(&"different");
2092
2093 let cache = cache();
2094 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2095 panic!("expected a free slot to claim");
2096 };
2097 assert!(matches!(
2098 lookup_or_claim(&cache, TOKEN, other).unwrap(),
2099 TokenClaim::Mismatch
2100 ));
2101
2102 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
2103 assert!(matches!(
2104 lookup_or_claim(&cache, TOKEN, other).unwrap(),
2105 TokenClaim::Mismatch
2106 ));
2107 }
2108
2109 #[test]
2110 fn the_claim_lands_before_the_call_is_polled() {
2111 let cache = cache();
2113 let claimed_first = Cell::new(false);
2114
2115 let out = drive(&cache, Some(TOKEN), async {
2116 let slots = cache
2119 .try_lock()
2120 .expect("the cache lock must be released before the call runs");
2121 claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
2122 Ok(7)
2123 });
2124
2125 assert_eq!(out.unwrap(), 7);
2126 assert!(claimed_first.get());
2127 }
2128
2129 #[test]
2130 fn a_caller_arriving_under_a_live_claim_does_not_execute() {
2131 let cache = cache();
2132 let second_ran = Cell::new(false);
2133
2134 let out = drive(&cache, Some(TOKEN), async {
2135 let second = drive(&cache, Some(TOKEN), async {
2136 second_ran.set(true);
2137 Ok(0)
2138 });
2139 assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
2142 assert!(matches!(
2145 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2146 TokenClaim::InFlight
2147 ));
2148 Ok(1)
2149 });
2150
2151 assert_eq!(out.unwrap(), 1);
2152 assert!(!second_ran.get(), "the second call must not run the work");
2153 assert!(matches!(
2155 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2156 TokenClaim::Hit(1)
2157 ));
2158 }
2159
2160 #[test]
2161 fn the_synchronous_driver_treats_a_live_claim_as_absent() {
2162 let cache = cache();
2167 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
2168
2169 let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
2170 assert_eq!(
2171 out, 9,
2172 "a claimed slot is not treated as a replayable result"
2173 );
2174 }
2175
2176 #[test]
2177 fn a_settled_token_replays_without_re_executing() {
2178 let cache = cache();
2179 let runs = Cell::new(0);
2180
2181 for _ in 0..2 {
2182 let out = drive(&cache, Some(TOKEN), async {
2183 runs.set(runs.get() + 1);
2184 Ok(7)
2185 });
2186 assert_eq!(out.unwrap(), 7);
2187 }
2188 assert_eq!(runs.get(), 1);
2189 }
2190
2191 #[test]
2192 fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
2193 let cache = cache();
2194 let runs = Cell::new(0);
2195
2196 let first = drive(&cache, Some(TOKEN), async {
2197 runs.set(runs.get() + 1);
2198 Err(DynoxideError::ValidationException("no".into()))
2199 });
2200 assert!(first.is_err());
2201 assert!(
2202 cache.lock().unwrap().is_empty(),
2203 "the claim must be released"
2204 );
2205
2206 let second = drive(&cache, Some(TOKEN), async {
2207 runs.set(runs.get() + 1);
2208 Ok(7)
2209 });
2210 assert_eq!(second.unwrap(), 7);
2211 assert_eq!(runs.get(), 2);
2212 }
2213
2214 #[test]
2215 fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
2216 let cache = cache();
2219 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
2220
2221 assert!(matches!(
2222 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
2223 TokenClaim::Marked(_)
2224 ));
2225 }
2226
2227 #[test]
2228 fn a_settled_token_stops_replaying_once_it_expires() {
2229 let cache = cache();
2230 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2231 panic!("expected a free slot to claim");
2232 };
2233 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
2234
2235 assert!(matches!(
2237 lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
2238 TokenClaim::Hit(7)
2239 ));
2240 assert!(matches!(
2242 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
2243 TokenClaim::Marked(_)
2244 ));
2245 }
2246
2247 #[test]
2248 fn concurrent_callers_under_one_token_execute_the_work_once() {
2249 use std::sync::Barrier;
2253 use std::sync::atomic::{AtomicUsize, Ordering};
2254
2255 const N: usize = 16;
2256 let cache = cache();
2257 let runs = AtomicUsize::new(0);
2258 let barrier = Barrier::new(N);
2259
2260 let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
2261 let handles: Vec<_> = (0..N)
2262 .map(|_| {
2263 scope.spawn(|| {
2264 barrier.wait();
2265 drive(&cache, Some(TOKEN), async {
2266 runs.fetch_add(1, Ordering::SeqCst);
2267 Ok(7)
2268 })
2269 })
2270 })
2271 .collect();
2272 handles.into_iter().map(|h| h.join().unwrap()).collect()
2273 });
2274
2275 assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
2276 for outcome in outcomes {
2279 match outcome {
2280 Ok(v) => assert_eq!(v, 7),
2281 Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
2282 }
2283 }
2284 }
2285
2286 #[test]
2287 fn an_overlong_token_is_rejected_with_dynamodbs_message() {
2288 let token = "x".repeat(MAX_TOKEN_LEN + 1);
2289 let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
2290 assert_eq!(
2291 err.to_string(),
2292 format!(
2293 "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
2294 )
2295 );
2296 }
2297
2298 #[test]
2299 fn a_tokenless_call_never_touches_the_cache() {
2300 let cache = cache();
2301 let runs = Cell::new(0);
2302
2303 for _ in 0..2 {
2304 drive(&cache, None, async {
2305 runs.set(runs.get() + 1);
2306 Ok(7)
2307 })
2308 .unwrap();
2309 }
2310 assert_eq!(runs.get(), 2);
2311 assert!(cache.lock().unwrap().is_empty());
2312 }
2313}