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 mod errors;
64pub mod expressions;
65#[cfg(feature = "import")]
66pub mod import;
67#[doc(hidden)]
68pub mod macros;
69#[cfg(feature = "mcp-server")]
70pub mod mcp;
71#[cfg(any(feature = "http-server", feature = "mcp-server"))]
72pub(crate) mod net;
73pub mod partiql;
74pub mod schema;
75pub(crate) mod serde_errors;
79#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
83pub(crate) mod serialization_checks;
84#[cfg(feature = "http-server")]
85pub mod server;
86#[cfg(feature = "mcp-server")]
87pub(crate) mod snapshots;
88pub mod storage;
89pub mod storage_backend;
90pub mod streams;
91pub mod ttl;
92pub mod types;
93pub mod validation;
94#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
98pub(crate) mod dynamo_ops;
99#[cfg(any(feature = "wasm-sqlite", test))]
104pub mod wasm_api;
105#[cfg(feature = "wasm-harness")]
106pub mod wasm_harness;
107
108#[doc(hidden)]
109pub use macros::ItemInsert;
110
111use std::collections::HashMap;
112use std::sync::{Arc, Mutex};
113use web_time::{Duration, Instant};
114
115pub use errors::{DynoxideError, Result};
116pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
117pub use storage_backend::BackendError;
118#[cfg(feature = "wasm-sqlite")]
119pub use storage_backend::WasmBridgeBackend;
120pub use types::{AttributeValue, ConversionError, Item};
121
122use actions::vector_lifecycle::VectorIndexLifecycle;
123
124#[derive(Debug, Clone, Default)]
126pub struct ImportOptions {
127 pub record_streams: bool,
129 pub set_cached_at: bool,
131}
132
133#[derive(Debug, Clone)]
135pub struct ImportResult {
136 pub items_imported: usize,
138 pub bytes_imported: usize,
140}
141
142type TokenSlot<T> = (Instant, u64, Option<T>);
150
151type TokenCache<T> = HashMap<String, TokenSlot<T>>;
153
154type TransactWriteTokenCache = TokenCache<actions::transact_write_items::CachedWrite>;
158
159type ExecuteTransactionTokenCache = TokenCache<actions::execute_transaction::CachedTransaction>;
165
166#[derive(Default)]
171pub struct TokenCaches {
172 #[cfg_attr(
176 not(any(feature = "native-sqlite", feature = "_has-encryption")),
177 allow(dead_code)
178 )]
179 transact_write: Mutex<TransactWriteTokenCache>,
180 execute_transaction: Mutex<ExecuteTransactionTokenCache>,
181}
182
183impl TokenCaches {
184 pub fn new() -> Self {
186 Self::default()
187 }
188
189 #[cfg(any(feature = "wasm-sqlite", test))]
190 pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
191 &self.execute_transaction
192 }
193}
194
195const MAX_TOKEN_LEN: usize = 36;
197
198const TOKEN_EXPIRY_SECS: u64 = 600;
201
202fn validate_token(token: Option<&str>) -> Result<()> {
204 match token {
205 Some(token) if token.len() > MAX_TOKEN_LEN => {
206 Err(DynoxideError::ValidationException(format!(
207 "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
208 )))
209 }
210 _ => Ok(()),
211 }
212}
213
214fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
221 use std::hash::{Hash, Hasher};
222 let normalised = serde_json::to_value(input)
223 .and_then(|v| serde_json::to_vec(&v))
224 .unwrap_or_default();
225 let mut hasher = std::collections::hash_map::DefaultHasher::new();
226 normalised.hash(&mut hasher);
227 hasher.finish()
228}
229
230fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
231 cache
232 .lock()
233 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
234}
235
236fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
242 cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
243}
244
245fn token_window() -> Duration {
247 Duration::from_secs(TOKEN_EXPIRY_SECS)
248}
249
250#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
268fn run_idempotent<T, H, E, R>(
269 cache: &Mutex<TokenCache<T>>,
270 token: Option<&str>,
271 hash_input: &H,
272 execute: E,
273 replay: R,
274) -> Result<T>
275where
276 T: Clone,
277 H: serde::Serialize,
278 E: FnOnce() -> Result<T>,
279 R: FnOnce(&T) -> T,
280{
281 validate_token(token)?;
282
283 let Some(token) = token else {
285 return execute();
286 };
287 let hash = request_hash(hash_input);
288
289 let mut cache = lock_cache(cache)?;
290 evict_expired(&mut cache, token_window());
291 let cached = match cache.get(token) {
292 Some((_, cached_hash, _)) if *cached_hash != hash => {
293 return Err(DynoxideError::IdempotentParameterMismatchException(
294 "An error occurred (IdempotentParameterMismatchException)".to_string(),
295 ));
296 }
297 Some((_, _, slot)) => slot.clone(),
301 None => None,
302 };
303 if let Some(cached) = cached {
304 drop(cache);
306 return Ok(replay(&cached));
307 }
308 let resp = execute()?;
312 cache.insert(
313 token.to_string(),
314 (Instant::now(), hash, Some(resp.clone())),
315 );
316 Ok(resp)
317}
318
319#[cfg(any(feature = "wasm-sqlite", test))]
322const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
323
324#[cfg(any(feature = "wasm-sqlite", test))]
326enum TokenClaim<T> {
327 Marked(Instant),
331 InFlight,
333 Mismatch,
335 Hit(T),
337}
338
339#[cfg(any(feature = "wasm-sqlite", test))]
345fn lookup_or_claim<T: Clone>(
346 cache: &Mutex<TokenCache<T>>,
347 token: &str,
348 hash: u64,
349) -> Result<TokenClaim<T>> {
350 lookup_or_claim_within(cache, token, hash, token_window())
351}
352
353#[cfg(any(feature = "wasm-sqlite", test))]
356fn lookup_or_claim_within<T: Clone>(
357 cache: &Mutex<TokenCache<T>>,
358 token: &str,
359 hash: u64,
360 window: Duration,
361) -> Result<TokenClaim<T>> {
362 let mut cache = lock_cache(cache)?;
363 evict_expired(&mut cache, window);
364 Ok(match cache.get(token) {
365 Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
366 Some((_, _, None)) => TokenClaim::InFlight,
367 Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
368 None => {
369 let claimed_at = Instant::now();
370 cache.insert(token.to_string(), (claimed_at, hash, None));
371 TokenClaim::Marked(claimed_at)
372 }
373 })
374}
375
376#[cfg(any(feature = "wasm-sqlite", test))]
383fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
384 matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
385}
386
387#[cfg(any(feature = "wasm-sqlite", test))]
389fn record_complete<T>(
390 cache: &Mutex<TokenCache<T>>,
391 token: &str,
392 claimed_at: Instant,
393 hash: u64,
394 resp: T,
395) -> Result<()> {
396 let mut cache = lock_cache(cache)?;
397 if still_ours(&cache, token, claimed_at) {
398 cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
399 }
400 Ok(())
401}
402
403#[cfg(any(feature = "wasm-sqlite", test))]
406fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
407 let mut cache = lock_cache(cache)?;
408 if still_ours(&cache, token, claimed_at) {
409 cache.remove(token);
410 }
411 Ok(())
412}
413
414#[cfg(any(feature = "wasm-sqlite", test))]
427async fn run_idempotent_async<T, H, F, R>(
428 cache: &Mutex<TokenCache<T>>,
429 token: Option<&str>,
430 hash_input: &H,
431 execute: F,
432 replay: R,
433) -> Result<T>
434where
435 T: Clone,
436 H: serde::Serialize,
437 F: std::future::Future<Output = Result<T>>,
438 R: FnOnce(&T) -> T,
439{
440 validate_token(token)?;
441
442 let Some(token) = token else {
443 return execute.await;
444 };
445 let hash = request_hash(hash_input);
446
447 let claimed_at = match lookup_or_claim(cache, token, hash)? {
448 TokenClaim::Hit(cached) => return Ok(replay(&cached)),
449 TokenClaim::Mismatch => {
450 return Err(DynoxideError::IdempotentParameterMismatchException(
451 "An error occurred (IdempotentParameterMismatchException)".to_string(),
452 ));
453 }
454 TokenClaim::InFlight => {
455 return Err(DynoxideError::InternalServerError(
456 IN_FLIGHT_MESSAGE.to_string(),
457 ));
458 }
459 TokenClaim::Marked(claimed_at) => claimed_at,
460 };
461
462 match execute.await {
463 Ok(resp) => {
464 let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
468 Ok(resp)
469 }
470 Err(e) => {
471 let _ = clear_claim(cache, token, claimed_at);
472 Err(e)
473 }
474 }
475}
476
477#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
482pub type RusqliteBackend = storage::Storage;
483
484#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
492pub type NativeDatabase = Database<RusqliteBackend>;
493
494#[cfg(feature = "wasm-sqlite")]
501pub type WasmDatabase = Database<WasmBridgeBackend>;
502
503#[cfg(feature = "wasm-sqlite")]
515pub const WASM_BACKEND: bool = true;
516#[cfg(not(feature = "wasm-sqlite"))]
519pub const WASM_BACKEND: bool = false;
520
521#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
530pub struct Database<S = RusqliteBackend> {
531 inner: Arc<Mutex<S>>,
532 tokens: Arc<TokenCaches>,
533 vector_lifecycle: Arc<VectorIndexLifecycle>,
534}
535
536#[cfg(all(
542 not(any(feature = "native-sqlite", feature = "_has-encryption")),
543 feature = "wasm-sqlite"
544))]
545use async_lock::Mutex as BackendMutex;
546#[cfg(all(
547 not(any(feature = "native-sqlite", feature = "_has-encryption")),
548 not(feature = "wasm-sqlite")
549))]
550use std::sync::Mutex as BackendMutex;
551
552#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
562pub struct Database<S> {
563 inner: Arc<BackendMutex<S>>,
564 tokens: Arc<TokenCaches>,
565 vector_lifecycle: Arc<VectorIndexLifecycle>,
566}
567
568impl<S> Clone for Database<S> {
570 fn clone(&self) -> Self {
571 Self {
572 inner: Arc::clone(&self.inner),
573 tokens: Arc::clone(&self.tokens),
574 vector_lifecycle: Arc::clone(&self.vector_lifecycle),
575 }
576 }
577}
578
579#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
580impl Database<RusqliteBackend> {
581 pub fn new(path: &str) -> Result<Self> {
583 let storage = storage::Storage::new(path)?;
584 Ok(Self {
585 inner: Arc::new(Mutex::new(storage)),
586 tokens: Arc::new(TokenCaches::new()),
587 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
588 })
589 }
590
591 #[cfg(feature = "_has-encryption")]
612 pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
613 if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
614 return Err(DynoxideError::ValidationException(
615 "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
616 ));
617 }
618
619 let storage = storage::Storage::new_encrypted(path, key)?;
620 Ok(Self {
621 inner: Arc::new(Mutex::new(storage)),
622 tokens: Arc::new(TokenCaches::new()),
623 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
624 })
625 }
626
627 pub fn memory() -> Result<Self> {
629 let storage = storage::Storage::memory()?;
630 Ok(Self {
631 inner: Arc::new(Mutex::new(storage)),
632 tokens: Arc::new(TokenCaches::new()),
633 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
634 })
635 }
636
637 pub fn memory_with_clock(clock: Arc<dyn storage_backend::Clock>) -> Result<Self> {
644 let storage = storage::Storage::memory()?.with_clock(clock);
645 Ok(Self {
646 inner: Arc::new(Mutex::new(storage)),
647 tokens: Arc::new(TokenCaches::new()),
648 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
649 })
650 }
651
652 pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
654 where
655 F: FnOnce(&storage::Storage) -> Result<T>,
656 {
657 let guard = self
658 .inner
659 .lock()
660 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
661 f(&guard)
662 }
663
664 pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
666 where
667 F: FnOnce(&mut storage::Storage) -> Result<T>,
668 {
669 let mut guard = self
670 .inner
671 .lock()
672 .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
673 f(&mut guard)
674 }
675
676 pub fn create_table(
682 &self,
683 request: actions::create_table::CreateTableRequest,
684 ) -> Result<actions::create_table::CreateTableResponse> {
685 self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
686 }
687
688 pub fn delete_table(
690 &self,
691 request: actions::delete_table::DeleteTableRequest,
692 ) -> Result<actions::delete_table::DeleteTableResponse> {
693 self.with_storage(|s| {
694 pollster::block_on(actions::delete_table::execute(
695 s,
696 request,
697 &self.vector_lifecycle,
698 ))
699 })
700 }
701
702 pub fn describe_table(
704 &self,
705 request: actions::describe_table::DescribeTableRequest,
706 ) -> Result<actions::describe_table::DescribeTableResponse> {
707 self.with_storage(|s| {
708 pollster::block_on(actions::describe_table::execute(
709 s,
710 request,
711 &self.vector_lifecycle,
712 ))
713 })
714 }
715
716 pub fn update_table(
718 &self,
719 request: actions::update_table::UpdateTableRequest,
720 ) -> Result<actions::update_table::UpdateTableResponse> {
721 self.with_storage(|s| {
722 pollster::block_on(actions::update_table::execute(
723 s,
724 request,
725 &self.vector_lifecycle,
726 ))
727 })
728 }
729
730 pub fn list_tables(
732 &self,
733 request: actions::list_tables::ListTablesRequest,
734 ) -> Result<actions::list_tables::ListTablesResponse> {
735 self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
736 }
737
738 pub fn tag_resource(
744 &self,
745 request: actions::tag_resource::TagResourceRequest,
746 ) -> Result<actions::tag_resource::TagResourceResponse> {
747 self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
748 }
749
750 pub fn untag_resource(
752 &self,
753 request: actions::untag_resource::UntagResourceRequest,
754 ) -> Result<actions::untag_resource::UntagResourceResponse> {
755 self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
756 }
757
758 pub fn list_tags_of_resource(
760 &self,
761 request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
762 ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
763 self.with_storage(|s| {
764 pollster::block_on(actions::list_tags_of_resource::execute(s, request))
765 })
766 }
767
768 pub fn put_item(
774 &self,
775 request: actions::put_item::PutItemRequest,
776 ) -> Result<actions::put_item::PutItemResponse> {
777 self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
778 }
779
780 pub fn get_item(
782 &self,
783 request: actions::get_item::GetItemRequest,
784 ) -> Result<actions::get_item::GetItemResponse> {
785 self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
786 }
787
788 pub fn delete_item(
790 &self,
791 request: actions::delete_item::DeleteItemRequest,
792 ) -> Result<actions::delete_item::DeleteItemResponse> {
793 self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
794 }
795
796 pub fn update_item(
798 &self,
799 request: actions::update_item::UpdateItemRequest,
800 ) -> Result<actions::update_item::UpdateItemResponse> {
801 self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
802 }
803
804 pub fn batch_get_item(
810 &self,
811 request: actions::batch_get_item::BatchGetItemRequest,
812 ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
813 self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
814 }
815
816 pub fn batch_write_item(
818 &self,
819 request: actions::batch_write_item::BatchWriteItemRequest,
820 ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
821 self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
822 }
823
824 pub fn import_items(
839 &self,
840 table_name: &str,
841 items: Vec<Item>,
842 options: ImportOptions,
843 ) -> Result<ImportResult> {
844 self.with_storage(|s| {
845 pollster::block_on(actions::import_items::execute(
846 s, table_name, items, &options,
847 ))
848 })
849 }
850
851 #[cfg(feature = "import")]
857 pub(crate) fn import_items_fresh(
858 &self,
859 table_name: &str,
860 items: Vec<Item>,
861 options: ImportOptions,
862 ) -> Result<ImportResult> {
863 self.with_storage(|s| {
864 pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
865 s, table_name, items, &options,
866 ))
867 })
868 }
869
870 pub fn enable_bulk_loading(&self) -> Result<()> {
879 self.with_storage(|s| s.enable_bulk_loading())
880 }
881
882 pub fn disable_bulk_loading(&self) -> Result<()> {
884 self.with_storage(|s| s.disable_bulk_loading())
885 }
886
887 pub fn query(
893 &self,
894 request: actions::query::QueryRequest,
895 ) -> Result<actions::query::QueryResponse> {
896 self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
897 }
898
899 pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
901 self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
902 }
903
904 pub fn search_vectors(
906 &self,
907 request: actions::search_vectors::SearchVectorsRequest,
908 ) -> Result<actions::search_vectors::SearchVectorsResponse> {
909 self.with_storage(|s| {
910 pollster::block_on(actions::search_vectors::execute(
911 s,
912 request,
913 &self.vector_lifecycle,
914 ))
915 })
916 }
917
918 pub fn transact_write_items(
929 &self,
930 request: actions::transact_write_items::TransactWriteItemsRequest,
931 ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
932 run_idempotent(
933 &self.tokens.transact_write,
934 request.client_request_token.as_deref(),
935 &request.transact_items,
936 || {
937 self.with_storage(|s| {
938 pollster::block_on(actions::transact_write_items::execute_cached(
939 s,
940 request.clone(),
941 ))
942 })
943 },
944 |cached| {
945 actions::transact_write_items::replay_response(
950 cached,
951 &request.return_consumed_capacity,
952 )
953 },
954 )
955 .map(|cached| cached.response)
956 }
957
958 pub fn transact_get_items(
960 &self,
961 request: actions::transact_get_items::TransactGetItemsRequest,
962 ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
963 self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
964 }
965
966 pub fn list_streams(
972 &self,
973 request: actions::list_streams::ListStreamsRequest,
974 ) -> Result<actions::list_streams::ListStreamsResponse> {
975 self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
976 }
977
978 pub fn describe_stream(
980 &self,
981 request: actions::describe_stream::DescribeStreamRequest,
982 ) -> Result<actions::describe_stream::DescribeStreamResponse> {
983 self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
984 }
985
986 pub fn get_shard_iterator(
988 &self,
989 request: actions::get_shard_iterator::GetShardIteratorRequest,
990 ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
991 self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
992 }
993
994 pub fn get_records(
996 &self,
997 request: actions::get_records::GetRecordsRequest,
998 ) -> Result<actions::get_records::GetRecordsResponse> {
999 self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
1000 }
1001
1002 pub fn update_time_to_live(
1008 &self,
1009 request: actions::update_time_to_live::UpdateTimeToLiveRequest,
1010 ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
1011 self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
1012 }
1013
1014 pub fn describe_time_to_live(
1016 &self,
1017 request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
1018 ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
1019 self.with_storage(|s| {
1020 pollster::block_on(actions::describe_time_to_live::execute(s, request))
1021 })
1022 }
1023
1024 pub fn sweep_ttl(&self) -> Result<usize> {
1027 self.with_storage(|s| pollster::block_on(ttl::sweep_expired_items(s)))
1028 }
1029
1030 pub fn execute_statement(
1036 &self,
1037 request: actions::execute_statement::ExecuteStatementRequest,
1038 ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
1039 self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
1040 }
1041
1042 pub fn execute_transaction(
1050 &self,
1051 request: actions::execute_transaction::ExecuteTransactionRequest,
1052 ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
1053 actions::execute_transaction::reject_bad_capacity_mode(
1057 request.return_consumed_capacity.as_deref(),
1058 )?;
1059 run_idempotent(
1060 &self.tokens.execute_transaction,
1061 request.client_request_token.as_deref(),
1062 &request.transact_statements,
1063 || {
1064 self.with_storage(|s| {
1065 pollster::block_on(actions::execute_transaction::execute_cached(
1066 s,
1067 request.clone(),
1068 ))
1069 })
1070 },
1071 |cached| {
1072 actions::execute_transaction::replay_response(
1075 cached,
1076 &request.return_consumed_capacity,
1077 )
1078 },
1079 )
1080 .map(|cached| cached.response)
1081 }
1082
1083 pub fn batch_execute_statement(
1085 &self,
1086 request: actions::batch_execute_statement::BatchExecuteStatementRequest,
1087 ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
1088 self.with_storage(|s| {
1089 pollster::block_on(actions::batch_execute_statement::execute(s, request))
1090 })
1091 }
1092
1093 pub fn touch_cached_at(
1102 &self,
1103 table_name: &str,
1104 pk: &str,
1105 sk: &str,
1106 timestamp: f64,
1107 ) -> Result<()> {
1108 self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
1109 }
1110
1111 pub fn get_lru_items(
1116 &self,
1117 table_name: &str,
1118 limit: usize,
1119 ) -> Result<Vec<(String, String, i64)>> {
1120 self.with_storage(|s| s.get_lru_items(table_name, limit))
1121 }
1122
1123 pub fn db_path(&self) -> Result<Option<String>> {
1129 self.with_storage(|s| Ok(s.db_path()))
1130 }
1131
1132 pub fn db_size_bytes(&self) -> Result<u64> {
1134 self.with_storage(|s| s.db_size_bytes())
1135 }
1136
1137 pub fn table_count(&self) -> Result<usize> {
1139 self.with_storage(|s| s.table_count())
1140 }
1141
1142 pub fn table_stats(&self) -> Result<Vec<TableStats>> {
1144 self.with_storage(|s| s.table_stats())
1145 }
1146
1147 pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
1149 self.with_storage(|s| s.get_table_metadata(table_name))
1150 }
1151
1152 pub fn database_info(&self) -> Result<DatabaseInfo> {
1157 self.with_storage(|s| s.database_info())
1158 }
1159
1160 pub fn vacuum(&self) -> Result<()> {
1166 self.with_storage(|s| s.vacuum())
1167 }
1168
1169 pub fn vacuum_into(&self, path: &str) -> Result<()> {
1174 self.with_storage(|s| s.vacuum_into(path))
1175 }
1176
1177 pub fn restore_from(&self, path: &str) -> Result<()> {
1183 self.with_storage_mut(|s| s.restore_from(path))
1184 }
1185
1186 #[cfg(feature = "mcp-server")]
1191 pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
1192 self.with_storage(|s| s.backup_to_memory())
1193 }
1194
1195 #[cfg(feature = "mcp-server")]
1199 pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
1200 self.with_storage_mut(|s| s.restore_from_connection(source))
1201 }
1202}
1203
1204#[cfg(feature = "wasm-sqlite")]
1218impl Database<WasmBridgeBackend> {
1219 pub async fn open(name: &str) -> Result<Self> {
1222 Self::open_with(name, false).await
1223 }
1224
1225 pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
1228 let backend = WasmBridgeBackend::open_with(name, ephemeral)
1229 .await
1230 .map_err(DynoxideError::from)?;
1231 Ok(Self {
1232 inner: Arc::new(BackendMutex::new(backend)),
1233 tokens: Arc::new(TokenCaches::new()),
1234 vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
1235 })
1236 }
1237
1238 pub async fn persistence_mode(&self) -> String {
1240 self.backend().await.persistence_mode().to_string()
1241 }
1242
1243 pub async fn close(&self) -> Result<()> {
1247 self.backend()
1248 .await
1249 .close()
1250 .await
1251 .map_err(DynoxideError::from)
1252 }
1253
1254 pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
1263 self.inner.lock().await
1264 }
1265
1266 pub(crate) fn token_caches(&self) -> &TokenCaches {
1270 &self.tokens
1271 }
1272
1273 pub(crate) fn vector_lifecycle(&self) -> &VectorIndexLifecycle {
1277 &self.vector_lifecycle
1278 }
1279
1280 pub async fn create_table(
1282 &self,
1283 request: actions::create_table::CreateTableRequest,
1284 ) -> Result<actions::create_table::CreateTableResponse> {
1285 let backend = self.backend().await;
1286 actions::create_table::execute(&*backend, request).await
1287 }
1288
1289 pub async fn delete_table(
1291 &self,
1292 request: actions::delete_table::DeleteTableRequest,
1293 ) -> Result<actions::delete_table::DeleteTableResponse> {
1294 let backend = self.backend().await;
1295 actions::delete_table::execute(&*backend, request, &self.vector_lifecycle).await
1296 }
1297
1298 pub async fn describe_table(
1300 &self,
1301 request: actions::describe_table::DescribeTableRequest,
1302 ) -> Result<actions::describe_table::DescribeTableResponse> {
1303 let backend = self.backend().await;
1304 actions::describe_table::execute(&*backend, request, &self.vector_lifecycle).await
1305 }
1306
1307 pub async fn list_tables(
1309 &self,
1310 request: actions::list_tables::ListTablesRequest,
1311 ) -> Result<actions::list_tables::ListTablesResponse> {
1312 let backend = self.backend().await;
1313 actions::list_tables::execute(&*backend, request).await
1314 }
1315
1316 pub async fn put_item(
1318 &self,
1319 request: actions::put_item::PutItemRequest,
1320 ) -> Result<actions::put_item::PutItemResponse> {
1321 let backend = self.backend().await;
1322 actions::put_item::execute(&*backend, request).await
1323 }
1324
1325 pub async fn get_item(
1327 &self,
1328 request: actions::get_item::GetItemRequest,
1329 ) -> Result<actions::get_item::GetItemResponse> {
1330 let backend = self.backend().await;
1331 actions::get_item::execute(&*backend, request).await
1332 }
1333
1334 pub async fn delete_item(
1336 &self,
1337 request: actions::delete_item::DeleteItemRequest,
1338 ) -> Result<actions::delete_item::DeleteItemResponse> {
1339 let backend = self.backend().await;
1340 actions::delete_item::execute(&*backend, request).await
1341 }
1342
1343 pub async fn query(
1345 &self,
1346 request: actions::query::QueryRequest,
1347 ) -> Result<actions::query::QueryResponse> {
1348 let backend = self.backend().await;
1349 actions::query::execute(&*backend, request).await
1350 }
1351
1352 pub async fn scan(
1354 &self,
1355 request: actions::scan::ScanRequest,
1356 ) -> Result<actions::scan::ScanResponse> {
1357 let backend = self.backend().await;
1358 actions::scan::execute(&*backend, request).await
1359 }
1360
1361 pub async fn search_vectors(
1365 &self,
1366 request: actions::search_vectors::SearchVectorsRequest,
1367 ) -> Result<actions::search_vectors::SearchVectorsResponse> {
1368 let backend = self.backend().await;
1369 actions::search_vectors::execute(&*backend, request, &self.vector_lifecycle).await
1370 }
1371}
1372
1373#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1374mod tests {
1375 use super::*;
1376
1377 #[test]
1378 fn test_database_memory() {
1379 let db = Database::memory().unwrap();
1380 let _db2 = db.clone();
1382 }
1383
1384 #[test]
1385 fn test_database_with_storage() {
1386 let db = Database::memory().unwrap();
1387 let tables = db.with_storage(|s| s.list_table_names()).unwrap();
1388 assert!(tables.is_empty());
1389 }
1390
1391 #[test]
1392 fn test_database_thread_safe() {
1393 let db = Database::memory().unwrap();
1394 let db2 = db.clone();
1395
1396 let handle =
1397 std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
1398
1399 let tables = handle.join().unwrap();
1400 assert!(tables.is_empty());
1401 }
1402
1403 #[test]
1404 fn test_native_database_alias_round_trips() {
1405 let db: NativeDatabase = Database::memory().unwrap();
1409
1410 db.create_table(actions::create_table::CreateTableRequest {
1411 table_name: "tbl".to_string(),
1412 key_schema: vec![types::KeySchemaElement {
1413 attribute_name: "pk".to_string(),
1414 key_type: types::KeyType::HASH,
1415 }],
1416 attribute_definitions: vec![types::AttributeDefinition {
1417 attribute_name: "pk".to_string(),
1418 attribute_type: types::ScalarAttributeType::S,
1419 }],
1420 ..Default::default()
1421 })
1422 .unwrap();
1423
1424 let mut item = HashMap::new();
1425 item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1426 db.put_item(actions::put_item::PutItemRequest {
1427 table_name: "tbl".to_string(),
1428 item,
1429 ..Default::default()
1430 })
1431 .unwrap();
1432
1433 let mut key = HashMap::new();
1434 key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1435 let got = db
1436 .get_item(actions::get_item::GetItemRequest {
1437 table_name: "tbl".to_string(),
1438 key,
1439 ..Default::default()
1440 })
1441 .unwrap();
1442 assert_eq!(
1443 got.item.unwrap().get("pk"),
1444 Some(&AttributeValue::S("a".to_string()))
1445 );
1446 }
1447}
1448
1449#[cfg(test)]
1455mod idempotency_tests {
1456 use super::*;
1457 use std::cell::Cell;
1458
1459 const KEY: &str = "statements";
1460 const TOKEN: &str = "tok";
1461
1462 fn cache() -> Mutex<TokenCache<u32>> {
1463 Mutex::new(HashMap::new())
1464 }
1465
1466 fn hash() -> u64 {
1467 request_hash(&KEY)
1468 }
1469
1470 fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
1471 where
1472 F: std::future::Future<Output = Result<u32>>,
1473 {
1474 pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
1475 }
1476
1477 #[test]
1478 fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
1479 let cache = cache();
1480
1481 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1482 panic!("the first caller should have claimed the token");
1483 };
1484 assert!(matches!(
1485 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1486 TokenClaim::InFlight
1487 ));
1488
1489 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1490 assert!(matches!(
1491 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1492 TokenClaim::Hit(7)
1493 ));
1494 }
1495
1496 #[test]
1497 fn only_one_caller_can_claim_a_token() {
1498 use std::sync::Barrier;
1502
1503 const N: usize = 16;
1504 let cache = cache();
1505 let barrier = Barrier::new(N);
1506
1507 let marked = std::thread::scope(|scope| {
1508 let handles: Vec<_> = (0..N)
1509 .map(|_| {
1510 scope.spawn(|| {
1511 barrier.wait();
1512 matches!(
1513 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1514 TokenClaim::Marked(_)
1515 )
1516 })
1517 })
1518 .collect();
1519 handles
1520 .into_iter()
1521 .map(|h| h.join().unwrap())
1522 .filter(|claimed| *claimed)
1523 .count()
1524 });
1525
1526 assert_eq!(marked, 1);
1527 }
1528
1529 #[test]
1530 fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
1531 let cache = cache();
1535 let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1536 panic!("the first caller should have claimed the token");
1537 };
1538 let TokenClaim::Marked(second) =
1539 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
1540 else {
1541 panic!("the expired claim should have been re-issued");
1542 };
1543 assert_ne!(first, second);
1544
1545 record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
1547 assert!(matches!(
1548 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1549 TokenClaim::InFlight
1550 ));
1551
1552 clear_claim(&cache, TOKEN, first).unwrap();
1554 assert!(matches!(
1555 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1556 TokenClaim::InFlight
1557 ));
1558
1559 record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
1561 assert!(matches!(
1562 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1563 TokenClaim::Hit(2)
1564 ));
1565 }
1566
1567 #[test]
1568 fn a_different_request_under_the_same_token_mismatches_in_both_states() {
1569 let other = request_hash(&"different");
1570
1571 let cache = cache();
1572 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1573 panic!("expected a free slot to claim");
1574 };
1575 assert!(matches!(
1576 lookup_or_claim(&cache, TOKEN, other).unwrap(),
1577 TokenClaim::Mismatch
1578 ));
1579
1580 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1581 assert!(matches!(
1582 lookup_or_claim(&cache, TOKEN, other).unwrap(),
1583 TokenClaim::Mismatch
1584 ));
1585 }
1586
1587 #[test]
1588 fn the_claim_lands_before_the_call_is_polled() {
1589 let cache = cache();
1591 let claimed_first = Cell::new(false);
1592
1593 let out = drive(&cache, Some(TOKEN), async {
1594 let slots = cache
1597 .try_lock()
1598 .expect("the cache lock must be released before the call runs");
1599 claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
1600 Ok(7)
1601 });
1602
1603 assert_eq!(out.unwrap(), 7);
1604 assert!(claimed_first.get());
1605 }
1606
1607 #[test]
1608 fn a_caller_arriving_under_a_live_claim_does_not_execute() {
1609 let cache = cache();
1610 let second_ran = Cell::new(false);
1611
1612 let out = drive(&cache, Some(TOKEN), async {
1613 let second = drive(&cache, Some(TOKEN), async {
1614 second_ran.set(true);
1615 Ok(0)
1616 });
1617 assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
1620 assert!(matches!(
1623 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1624 TokenClaim::InFlight
1625 ));
1626 Ok(1)
1627 });
1628
1629 assert_eq!(out.unwrap(), 1);
1630 assert!(!second_ran.get(), "the second call must not run the work");
1631 assert!(matches!(
1633 lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1634 TokenClaim::Hit(1)
1635 ));
1636 }
1637
1638 #[test]
1639 fn the_synchronous_driver_treats_a_live_claim_as_absent() {
1640 let cache = cache();
1645 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
1646
1647 let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
1648 assert_eq!(
1649 out, 9,
1650 "a claimed slot is not treated as a replayable result"
1651 );
1652 }
1653
1654 #[test]
1655 fn a_settled_token_replays_without_re_executing() {
1656 let cache = cache();
1657 let runs = Cell::new(0);
1658
1659 for _ in 0..2 {
1660 let out = drive(&cache, Some(TOKEN), async {
1661 runs.set(runs.get() + 1);
1662 Ok(7)
1663 });
1664 assert_eq!(out.unwrap(), 7);
1665 }
1666 assert_eq!(runs.get(), 1);
1667 }
1668
1669 #[test]
1670 fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
1671 let cache = cache();
1672 let runs = Cell::new(0);
1673
1674 let first = drive(&cache, Some(TOKEN), async {
1675 runs.set(runs.get() + 1);
1676 Err(DynoxideError::ValidationException("no".into()))
1677 });
1678 assert!(first.is_err());
1679 assert!(
1680 cache.lock().unwrap().is_empty(),
1681 "the claim must be released"
1682 );
1683
1684 let second = drive(&cache, Some(TOKEN), async {
1685 runs.set(runs.get() + 1);
1686 Ok(7)
1687 });
1688 assert_eq!(second.unwrap(), 7);
1689 assert_eq!(runs.get(), 2);
1690 }
1691
1692 #[test]
1693 fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
1694 let cache = cache();
1697 lookup_or_claim(&cache, TOKEN, hash()).unwrap();
1698
1699 assert!(matches!(
1700 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
1701 TokenClaim::Marked(_)
1702 ));
1703 }
1704
1705 #[test]
1706 fn a_settled_token_stops_replaying_once_it_expires() {
1707 let cache = cache();
1708 let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1709 panic!("expected a free slot to claim");
1710 };
1711 record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1712
1713 assert!(matches!(
1715 lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
1716 TokenClaim::Hit(7)
1717 ));
1718 assert!(matches!(
1720 lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
1721 TokenClaim::Marked(_)
1722 ));
1723 }
1724
1725 #[test]
1726 fn concurrent_callers_under_one_token_execute_the_work_once() {
1727 use std::sync::Barrier;
1731 use std::sync::atomic::{AtomicUsize, Ordering};
1732
1733 const N: usize = 16;
1734 let cache = cache();
1735 let runs = AtomicUsize::new(0);
1736 let barrier = Barrier::new(N);
1737
1738 let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
1739 let handles: Vec<_> = (0..N)
1740 .map(|_| {
1741 scope.spawn(|| {
1742 barrier.wait();
1743 drive(&cache, Some(TOKEN), async {
1744 runs.fetch_add(1, Ordering::SeqCst);
1745 Ok(7)
1746 })
1747 })
1748 })
1749 .collect();
1750 handles.into_iter().map(|h| h.join().unwrap()).collect()
1751 });
1752
1753 assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
1754 for outcome in outcomes {
1757 match outcome {
1758 Ok(v) => assert_eq!(v, 7),
1759 Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
1760 }
1761 }
1762 }
1763
1764 #[test]
1765 fn an_overlong_token_is_rejected_with_dynamodbs_message() {
1766 let token = "x".repeat(MAX_TOKEN_LEN + 1);
1767 let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
1768 assert_eq!(
1769 err.to_string(),
1770 format!(
1771 "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
1772 )
1773 );
1774 }
1775
1776 #[test]
1777 fn a_tokenless_call_never_touches_the_cache() {
1778 let cache = cache();
1779 let runs = Cell::new(0);
1780
1781 for _ in 0..2 {
1782 drive(&cache, None, async {
1783 runs.set(runs.get() + 1);
1784 Ok(7)
1785 })
1786 .unwrap();
1787 }
1788 assert_eq!(runs.get(), 2);
1789 assert!(cache.lock().unwrap().is_empty());
1790 }
1791}