Skip to main content

dynoxide/
lib.rs

1//! # Dynoxide
2//!
3//! A lightweight, embeddable DynamoDB emulator backed by SQLite.
4//!
5//! ```rust
6//! use dynoxide::Database;
7//!
8//! let db = Database::memory().unwrap();
9//! ```
10
11#[cfg(all(feature = "native-sqlite", feature = "_has-encryption"))]
12compile_error!(
13    "Features `native-sqlite` and `encryption`/`encryption-cc` are mutually exclusive.\n\
14     If you ran `cargo install`, use:\n  \
15     cargo install dynoxide-rs --no-default-features --features encrypted-server\n\
16     If using as a library dependency, set `default-features = false` \
17     and enable only one backend."
18);
19
20#[cfg(all(feature = "encryption", feature = "encryption-cc"))]
21compile_error!(
22    "Features `encryption` and `encryption-cc` are mutually exclusive. \
23     Use `encryption` for vendored OpenSSL or `encryption-cc` for Apple CommonCrypto."
24);
25
26#[cfg(all(feature = "encryption-cc", not(target_vendor = "apple")))]
27compile_error!(
28    "The `encryption-cc` feature is intended for Apple platforms only (CommonCrypto). \
29     Use the `encryption` feature for vendored OpenSSL on non-Apple platforms."
30);
31
32#[cfg(not(any(
33    feature = "native-sqlite",
34    feature = "_has-encryption",
35    feature = "wasm-sqlite"
36)))]
37compile_error!(
38    "A storage backend feature must be enabled: `native-sqlite`, `encryption`, \
39     `encryption-cc`, or `wasm-sqlite`. Default features include `native-sqlite`. \
40     If you used `default-features = false`, add one of these features."
41);
42
43pub mod actions;
44pub mod errors;
45pub mod expressions;
46#[cfg(feature = "import")]
47pub mod import;
48#[doc(hidden)]
49pub mod macros;
50#[cfg(feature = "mcp-server")]
51pub mod mcp;
52pub mod partiql;
53pub mod schema;
54#[cfg(feature = "http-server")]
55pub mod server;
56#[cfg(feature = "mcp-server")]
57pub(crate) mod snapshots;
58pub mod storage;
59pub mod storage_backend;
60pub mod streams;
61pub mod ttl;
62pub mod types;
63pub mod validation;
64#[cfg(feature = "wasm-harness")]
65pub mod wasm_harness;
66
67#[doc(hidden)]
68pub use macros::ItemInsert;
69
70use std::collections::HashMap;
71use std::sync::{Arc, Mutex};
72use web_time::Instant;
73
74pub use errors::{DynoxideError, Result};
75pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
76pub use storage_backend::BackendError;
77#[cfg(feature = "wasm-sqlite")]
78pub use storage_backend::WasmBridgeBackend;
79pub use types::{AttributeValue, ConversionError, Item};
80
81/// Options for `Database::import_items()`.
82#[derive(Debug, Clone, Default)]
83pub struct ImportOptions {
84    /// Whether to record stream events for imported items. Default: false.
85    pub record_streams: bool,
86    /// Whether to set `cached_at` to the current timestamp. Default: false.
87    pub set_cached_at: bool,
88}
89
90/// Result of a bulk import operation.
91#[derive(Debug, Clone)]
92pub struct ImportResult {
93    /// Number of items imported.
94    pub items_imported: usize,
95    /// Total bytes imported (sum of item_size values).
96    pub bytes_imported: usize,
97}
98
99/// Cached transaction response with timestamp and request hash for idempotency.
100type TokenCache = HashMap<
101    String,
102    (
103        Instant,
104        u64,
105        actions::transact_write_items::TransactWriteItemsResponse,
106    ),
107>;
108
109/// The native storage backend: the rusqlite-backed [`storage::Storage`].
110///
111/// `Database`'s type parameter defaults to this, so existing native callers
112/// keep writing `Database` and get the synchronous rusqlite-backed engine.
113#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
114pub type RusqliteBackend = storage::Storage;
115
116/// The native, synchronous `Database`.
117///
118/// Alias for the default [`Database`] monomorphisation over
119/// [`RusqliteBackend`]. It exposes the historical synchronous public API
120/// unchanged: each method drives an async handler future to completion with
121/// `block_on`. Because the native backend's futures never suspend, that
122/// `block_on` never parks the thread.
123#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
124pub type NativeDatabase = Database<RusqliteBackend>;
125
126/// The wasm, asynchronous `Database` over the wa-sqlite backend.
127///
128/// Alias for [`Database`] monomorphised over [`WasmBridgeBackend`]. Unlike
129/// [`NativeDatabase`], its methods are `async fn` and never call `block_on`:
130/// the wasm backend awaits real wa-sqlite promises, and the wasm main thread
131/// must not block.
132#[cfg(feature = "wasm-sqlite")]
133pub type WasmDatabase = Database<WasmBridgeBackend>;
134
135/// Build-visible preview marker for the wasm-sqlite backend.
136///
137/// `true` when built with `--features wasm-sqlite`, `false` otherwise. The wasm
138/// backend covers CRUD, query, scan, and GSI/LSI, but it is not run against the
139/// dynamodb-conformance suite that covers the native build. Consumers can read
140/// this constant to tell whether the artifact they hold is the conformance-
141/// tested native build or the wasm preview.
142#[cfg(feature = "wasm-sqlite")]
143pub const WASM_PREVIEW: bool = true;
144/// Build-visible preview marker for the wasm-sqlite backend. See the
145/// `wasm-sqlite` variant for details.
146#[cfg(not(feature = "wasm-sqlite"))]
147pub const WASM_PREVIEW: bool = false;
148
149/// The main entry point for the DynamoDB emulator.
150///
151/// Generic over the storage backend `S`, monomorphised (no `dyn`). The type
152/// parameter defaults to [`RusqliteBackend`], so `Database` means the native
153/// engine and the public synchronous API is preserved via [`NativeDatabase`].
154///
155/// Wraps a storage layer and provides DynamoDB-compatible operations.
156/// Thread-safe via `Arc<Mutex<>>`, so clone freely across threads.
157#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
158pub struct Database<S = RusqliteBackend> {
159    inner: Arc<Mutex<S>>,
160    idempotency_tokens: Arc<Mutex<TokenCache>>,
161}
162
163/// Serialises backend access on the backend-neutral build. On wasm this is an
164/// async mutex: the bridge calls genuinely suspend, so a std mutex held across
165/// them would deadlock concurrent callers on the single-threaded runtime,
166/// whereas an async mutex queues them. Off wasm (the degenerate no-backend
167/// shell, which can never construct a `Database`) a std mutex stands in.
168#[cfg(all(
169    not(any(feature = "native-sqlite", feature = "_has-encryption")),
170    feature = "wasm-sqlite"
171))]
172use async_lock::Mutex as BackendMutex;
173#[cfg(all(
174    not(any(feature = "native-sqlite", feature = "_has-encryption")),
175    not(feature = "wasm-sqlite")
176))]
177use std::sync::Mutex as BackendMutex;
178
179/// The main entry point for the DynamoDB emulator (backend-neutral build).
180///
181/// On a build with no native backend (for example the `wasm-sqlite` build)
182/// there is no native default, so the backend must be named explicitly - for
183/// example `Database<WasmBridgeBackend>`, aliased as `WasmDatabase`.
184///
185/// Wraps a storage layer and provides DynamoDB-compatible operations. Backend
186/// access is serialised by [`BackendMutex`] (an async mutex on wasm); clone
187/// freely, only the `Arc`s are copied.
188#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
189pub struct Database<S> {
190    inner: Arc<BackendMutex<S>>,
191    idempotency_tokens: Arc<Mutex<TokenCache>>,
192}
193
194// Hand-written so cloning never requires `S: Clone`; only the `Arc`s clone.
195impl<S> Clone for Database<S> {
196    fn clone(&self) -> Self {
197        Self {
198            inner: Arc::clone(&self.inner),
199            idempotency_tokens: Arc::clone(&self.idempotency_tokens),
200        }
201    }
202}
203
204#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
205impl Database<RusqliteBackend> {
206    /// Open a persistent database at the given path.
207    pub fn new(path: &str) -> Result<Self> {
208        let storage = storage::Storage::new(path)?;
209        Ok(Self {
210            inner: Arc::new(Mutex::new(storage)),
211            idempotency_tokens: Arc::new(Mutex::new(HashMap::new())),
212        })
213    }
214
215    /// Open or create an encrypted database at the given path.
216    ///
217    /// The key must be a 64-character hex string representing a 32-byte key.
218    /// Example: `"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"`
219    ///
220    /// The key is passed to SQLCipher via `PRAGMA key`. The database file is
221    /// encrypted at rest using AES-256-CBC.
222    ///
223    /// # Security
224    ///
225    /// This function borrows the key as `&str` and cannot zeroize the caller's
226    /// copy. The caller is responsible for zeroizing owned key material after
227    /// this call returns (e.g., by using `zeroize::Zeroizing<String>`).
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if:
232    /// - The key format is invalid (not 64 hex characters)
233    /// - The database exists but was created without encryption
234    /// - The database exists but the key is wrong
235    #[cfg(feature = "_has-encryption")]
236    pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
237        if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
238            return Err(DynoxideError::ValidationException(
239                "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
240            ));
241        }
242
243        let storage = storage::Storage::new_encrypted(path, key)?;
244        Ok(Self {
245            inner: Arc::new(Mutex::new(storage)),
246            idempotency_tokens: Arc::new(Mutex::new(HashMap::new())),
247        })
248    }
249
250    /// Open an in-memory database (for tests and ephemeral use).
251    pub fn memory() -> Result<Self> {
252        let storage = storage::Storage::memory()?;
253        Ok(Self {
254            inner: Arc::new(Mutex::new(storage)),
255            idempotency_tokens: Arc::new(Mutex::new(HashMap::new())),
256        })
257    }
258
259    /// Execute a closure with exclusive access to the storage layer.
260    pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
261    where
262        F: FnOnce(&storage::Storage) -> Result<T>,
263    {
264        let guard = self
265            .inner
266            .lock()
267            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
268        f(&guard)
269    }
270
271    /// Execute a closure with mutable exclusive access to the storage layer.
272    pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
273    where
274        F: FnOnce(&mut storage::Storage) -> Result<T>,
275    {
276        let mut guard = self
277            .inner
278            .lock()
279            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
280        f(&mut guard)
281    }
282
283    // -------------------------------------------------------------------
284    // Table operations
285    // -------------------------------------------------------------------
286
287    /// Create a new DynamoDB table.
288    pub fn create_table(
289        &self,
290        request: actions::create_table::CreateTableRequest,
291    ) -> Result<actions::create_table::CreateTableResponse> {
292        self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
293    }
294
295    /// Delete a DynamoDB table.
296    pub fn delete_table(
297        &self,
298        request: actions::delete_table::DeleteTableRequest,
299    ) -> Result<actions::delete_table::DeleteTableResponse> {
300        self.with_storage(|s| pollster::block_on(actions::delete_table::execute(s, request)))
301    }
302
303    /// Describe a DynamoDB table.
304    pub fn describe_table(
305        &self,
306        request: actions::describe_table::DescribeTableRequest,
307    ) -> Result<actions::describe_table::DescribeTableResponse> {
308        self.with_storage(|s| pollster::block_on(actions::describe_table::execute(s, request)))
309    }
310
311    /// Update a DynamoDB table (add/remove GSIs).
312    pub fn update_table(
313        &self,
314        request: actions::update_table::UpdateTableRequest,
315    ) -> Result<actions::update_table::UpdateTableResponse> {
316        self.with_storage(|s| pollster::block_on(actions::update_table::execute(s, request)))
317    }
318
319    /// List DynamoDB tables.
320    pub fn list_tables(
321        &self,
322        request: actions::list_tables::ListTablesRequest,
323    ) -> Result<actions::list_tables::ListTablesResponse> {
324        self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
325    }
326
327    // -------------------------------------------------------------------
328    // Tags
329    // -------------------------------------------------------------------
330
331    /// Add tags to a DynamoDB table.
332    pub fn tag_resource(
333        &self,
334        request: actions::tag_resource::TagResourceRequest,
335    ) -> Result<actions::tag_resource::TagResourceResponse> {
336        self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
337    }
338
339    /// Remove tags from a DynamoDB table.
340    pub fn untag_resource(
341        &self,
342        request: actions::untag_resource::UntagResourceRequest,
343    ) -> Result<actions::untag_resource::UntagResourceResponse> {
344        self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
345    }
346
347    /// List tags for a DynamoDB table.
348    pub fn list_tags_of_resource(
349        &self,
350        request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
351    ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
352        self.with_storage(|s| {
353            pollster::block_on(actions::list_tags_of_resource::execute(s, request))
354        })
355    }
356
357    // -------------------------------------------------------------------
358    // Item operations
359    // -------------------------------------------------------------------
360
361    /// Put an item into a DynamoDB table.
362    pub fn put_item(
363        &self,
364        request: actions::put_item::PutItemRequest,
365    ) -> Result<actions::put_item::PutItemResponse> {
366        self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
367    }
368
369    /// Get an item from a DynamoDB table.
370    pub fn get_item(
371        &self,
372        request: actions::get_item::GetItemRequest,
373    ) -> Result<actions::get_item::GetItemResponse> {
374        self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
375    }
376
377    /// Delete an item from a DynamoDB table.
378    pub fn delete_item(
379        &self,
380        request: actions::delete_item::DeleteItemRequest,
381    ) -> Result<actions::delete_item::DeleteItemResponse> {
382        self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
383    }
384
385    /// Update an item in a DynamoDB table.
386    pub fn update_item(
387        &self,
388        request: actions::update_item::UpdateItemRequest,
389    ) -> Result<actions::update_item::UpdateItemResponse> {
390        self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
391    }
392
393    // -------------------------------------------------------------------
394    // Batch operations
395    // -------------------------------------------------------------------
396
397    /// Batch get items from one or more DynamoDB tables.
398    pub fn batch_get_item(
399        &self,
400        request: actions::batch_get_item::BatchGetItemRequest,
401    ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
402        self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
403    }
404
405    /// Batch write items to one or more DynamoDB tables.
406    pub fn batch_write_item(
407        &self,
408        request: actions::batch_write_item::BatchWriteItemRequest,
409    ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
410        self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
411    }
412
413    /// Import items in bulk, bypassing per-item size validation.
414    ///
415    /// All items are inserted in a single transaction. If any item fails,
416    /// the entire import is rolled back. Items with duplicate keys within
417    /// the batch are resolved by last-write-wins (later items in the vec
418    /// overwrite earlier items with the same primary key).
419    ///
420    /// GSI entries are maintained: items with GSI key attributes are
421    /// inserted into the appropriate GSI tables. Items missing GSI key
422    /// attributes are silently omitted from the GSI (sparse GSI behavior,
423    /// matching DynamoDB semantics).
424    ///
425    /// Stream records are NOT generated by default. Use
426    /// `ImportOptions { record_streams: true, .. }` if stream recording is needed.
427    pub fn import_items(
428        &self,
429        table_name: &str,
430        items: Vec<Item>,
431        options: ImportOptions,
432    ) -> Result<ImportResult> {
433        self.with_storage(|s| {
434            pollster::block_on(actions::import_items::execute(
435                s, table_name, items, &options,
436            ))
437        })
438    }
439
440    /// Import items in bulk, skipping GSI DELETE-before-INSERT.
441    ///
442    /// Same as `import_items` but assumes the database is fresh (no
443    /// pre-existing rows), so GSI cleanup deletes are skipped entirely.
444    /// This eliminates the dominant bottleneck for large imports.
445    #[cfg(feature = "import")]
446    pub(crate) fn import_items_fresh(
447        &self,
448        table_name: &str,
449        items: Vec<Item>,
450        options: ImportOptions,
451    ) -> Result<ImportResult> {
452        self.with_storage(|s| {
453            pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
454                s, table_name, items, &options,
455            ))
456        })
457    }
458
459    // -------------------------------------------------------------------
460    // Bulk loading
461    // -------------------------------------------------------------------
462
463    /// Set aggressive SQLite PRAGMAs for bulk loading.
464    ///
465    /// Only safe when data loss on crash is acceptable (e.g., fresh import).
466    /// Call `disable_bulk_loading()` after the import to restore normal settings.
467    pub fn enable_bulk_loading(&self) -> Result<()> {
468        self.with_storage(|s| s.enable_bulk_loading())
469    }
470
471    /// Restore normal SQLite PRAGMAs after bulk loading.
472    pub fn disable_bulk_loading(&self) -> Result<()> {
473        self.with_storage(|s| s.disable_bulk_loading())
474    }
475
476    // -------------------------------------------------------------------
477    // Query & Scan
478    // -------------------------------------------------------------------
479
480    /// Query a DynamoDB table.
481    pub fn query(
482        &self,
483        request: actions::query::QueryRequest,
484    ) -> Result<actions::query::QueryResponse> {
485        self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
486    }
487
488    /// Scan a DynamoDB table.
489    pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
490        self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
491    }
492
493    // -------------------------------------------------------------------
494    // Transactions
495    // -------------------------------------------------------------------
496
497    /// Execute a transactional write (up to 100 actions, all-or-nothing).
498    pub fn transact_write_items(
499        &self,
500        request: actions::transact_write_items::TransactWriteItemsRequest,
501    ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
502        const TOKEN_EXPIRY_SECS: u64 = 600; // 10 minutes
503        const MAX_TOKEN_LEN: usize = 36;
504
505        // Validate token length
506        if let Some(ref token) = request.client_request_token {
507            if token.len() > MAX_TOKEN_LEN {
508                return Err(DynoxideError::ValidationException(format!(
509                    "1 validation error detected: Value '{}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {}",
510                    token, MAX_TOKEN_LEN
511                )));
512            }
513        }
514
515        // Compute request hash for idempotency comparison.
516        // Normalise via serde_json::Value (uses BTreeMap internally) to ensure
517        // deterministic key ordering regardless of HashMap iteration order.
518        let request_hash = if request.client_request_token.is_some() {
519            use std::hash::{Hash, Hasher};
520            let normalised = serde_json::to_value(&request.transact_items)
521                .and_then(|v| serde_json::to_vec(&v))
522                .unwrap_or_default();
523            let mut hasher = std::collections::hash_map::DefaultHasher::new();
524            normalised.hash(&mut hasher);
525            hasher.finish()
526        } else {
527            0
528        };
529
530        // Check idempotency cache
531        if let Some(ref token) = request.client_request_token {
532            let mut cache = self
533                .idempotency_tokens
534                .lock()
535                .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
536            // Evict expired entries
537            cache.retain(|_, (ts, _, _)| ts.elapsed().as_secs() < TOKEN_EXPIRY_SECS);
538            if let Some((_, cached_hash, resp)) = cache.get(token) {
539                if *cached_hash != request_hash {
540                    return Err(DynoxideError::IdempotentParameterMismatchException(
541                        "An error occurred (IdempotentParameterMismatchException)".to_string(),
542                    ));
543                }
544                return Ok(resp.clone());
545            }
546        }
547
548        let resp = self.with_storage(|s| {
549            pollster::block_on(actions::transact_write_items::execute(s, request.clone()))
550        })?;
551
552        // Cache the response if token was provided
553        if let Some(ref token) = request.client_request_token {
554            if let Ok(mut cache) = self.idempotency_tokens.lock() {
555                cache.insert(token.clone(), (Instant::now(), request_hash, resp.clone()));
556            }
557        }
558
559        Ok(resp)
560    }
561
562    /// Execute a transactional read (up to 100 gets).
563    pub fn transact_get_items(
564        &self,
565        request: actions::transact_get_items::TransactGetItemsRequest,
566    ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
567        self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
568    }
569
570    // -------------------------------------------------------------------
571    // Streams
572    // -------------------------------------------------------------------
573
574    /// List DynamoDB Streams.
575    pub fn list_streams(
576        &self,
577        request: actions::list_streams::ListStreamsRequest,
578    ) -> Result<actions::list_streams::ListStreamsResponse> {
579        self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
580    }
581
582    /// Describe a DynamoDB Stream.
583    pub fn describe_stream(
584        &self,
585        request: actions::describe_stream::DescribeStreamRequest,
586    ) -> Result<actions::describe_stream::DescribeStreamResponse> {
587        self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
588    }
589
590    /// Get a shard iterator.
591    pub fn get_shard_iterator(
592        &self,
593        request: actions::get_shard_iterator::GetShardIteratorRequest,
594    ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
595        self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
596    }
597
598    /// Get stream records.
599    pub fn get_records(
600        &self,
601        request: actions::get_records::GetRecordsRequest,
602    ) -> Result<actions::get_records::GetRecordsResponse> {
603        self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
604    }
605
606    // -------------------------------------------------------------------
607    // TTL
608    // -------------------------------------------------------------------
609
610    /// Update time to live configuration.
611    pub fn update_time_to_live(
612        &self,
613        request: actions::update_time_to_live::UpdateTimeToLiveRequest,
614    ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
615        self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
616    }
617
618    /// Describe time to live configuration.
619    pub fn describe_time_to_live(
620        &self,
621        request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
622    ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
623        self.with_storage(|s| {
624            pollster::block_on(actions::describe_time_to_live::execute(s, request))
625        })
626    }
627
628    /// Run a TTL sweep, deleting expired items from all TTL-enabled tables.
629    /// Returns the number of items deleted.
630    pub fn sweep_ttl(&self) -> Result<usize> {
631        self.with_storage(|s| pollster::block_on(ttl::sweep_expired_items(s)))
632    }
633
634    // -------------------------------------------------------------------
635    // PartiQL
636    // -------------------------------------------------------------------
637
638    /// Execute a single PartiQL statement.
639    pub fn execute_statement(
640        &self,
641        request: actions::execute_statement::ExecuteStatementRequest,
642    ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
643        self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
644    }
645
646    /// Execute PartiQL statements transactionally (all-or-nothing).
647    pub fn execute_transaction(
648        &self,
649        request: actions::execute_transaction::ExecuteTransactionRequest,
650    ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
651        self.with_storage(|s| pollster::block_on(actions::execute_transaction::execute(s, request)))
652    }
653
654    /// Execute a batch of PartiQL statements.
655    pub fn batch_execute_statement(
656        &self,
657        request: actions::batch_execute_statement::BatchExecuteStatementRequest,
658    ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
659        self.with_storage(|s| {
660            pollster::block_on(actions::batch_execute_statement::execute(s, request))
661        })
662    }
663
664    // -------------------------------------------------------------------
665    // Cache tracking
666    // -------------------------------------------------------------------
667
668    /// Update the `cached_at` timestamp for a single item.
669    ///
670    /// Used by cache layers to track when items were last fetched from a
671    /// remote source. The timestamp is a Unix epoch in seconds (f64).
672    pub fn touch_cached_at(
673        &self,
674        table_name: &str,
675        pk: &str,
676        sk: &str,
677        timestamp: f64,
678    ) -> Result<()> {
679        self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
680    }
681
682    /// Get items ordered by `cached_at` (oldest first) for LRU eviction.
683    ///
684    /// Returns `(pk, sk, item_size)` tuples. Items with NULL `cached_at`
685    /// are excluded (they were never cached from a remote source).
686    pub fn get_lru_items(
687        &self,
688        table_name: &str,
689        limit: usize,
690    ) -> Result<Vec<(String, String, i64)>> {
691        self.with_storage(|s| s.get_lru_items(table_name, limit))
692    }
693
694    // -------------------------------------------------------------------
695    // Introspection
696    // -------------------------------------------------------------------
697
698    /// Get the database file path, or `None` for in-memory databases.
699    pub fn db_path(&self) -> Result<Option<String>> {
700        self.with_storage(|s| Ok(s.db_path()))
701    }
702
703    /// Get the total database size in bytes.
704    pub fn db_size_bytes(&self) -> Result<u64> {
705        self.with_storage(|s| s.db_size_bytes())
706    }
707
708    /// Count the number of DynamoDB tables.
709    pub fn table_count(&self) -> Result<usize> {
710        self.with_storage(|s| s.table_count())
711    }
712
713    /// Get per-table statistics: name, item count, and approximate size in bytes.
714    pub fn table_stats(&self) -> Result<Vec<TableStats>> {
715        self.with_storage(|s| s.table_stats())
716    }
717
718    /// Get metadata for a specific table (key schema, GSIs, TTL config, etc.).
719    pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
720        self.with_storage(|s| s.get_table_metadata(table_name))
721    }
722
723    /// Get combined database info atomically in a single lock acquisition.
724    ///
725    /// Returns path, size, table count, and per-table stats + metadata.
726    /// Avoids the consistency issues of calling individual methods separately.
727    pub fn database_info(&self) -> Result<DatabaseInfo> {
728        self.with_storage(|s| s.database_info())
729    }
730
731    // -------------------------------------------------------------------
732    // Snapshot operations
733    // -------------------------------------------------------------------
734
735    /// Run VACUUM to compact the database file in place.
736    pub fn vacuum(&self) -> Result<()> {
737        self.with_storage(|s| s.vacuum())
738    }
739
740    /// Create a snapshot of the database by copying it to the given path.
741    ///
742    /// Uses SQLite's `VACUUM INTO` which works for both in-memory and
743    /// file-backed databases. The snapshot is a standalone SQLite file.
744    pub fn vacuum_into(&self, path: &str) -> Result<()> {
745        self.with_storage(|s| s.vacuum_into(path))
746    }
747
748    /// Restore the database from a snapshot file.
749    ///
750    /// Uses SQLite's backup API to replace the current database contents
751    /// with the snapshot. Works for both in-memory and file-backed databases.
752    /// The backup is atomic — either all pages are copied or none are.
753    pub fn restore_from(&self, path: &str) -> Result<()> {
754        self.with_storage_mut(|s| s.restore_from(path))
755    }
756
757    /// Backup the current database to a new in-memory SQLite connection.
758    ///
759    /// Returns an owned `Connection` holding a complete copy. Used for
760    /// in-memory snapshot storage — no filesystem side-effects.
761    #[cfg(feature = "mcp-server")]
762    pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
763        self.with_storage(|s| s.backup_to_memory())
764    }
765
766    /// Restore the database from an in-memory SQLite connection.
767    ///
768    /// Replaces current contents with the source connection's data.
769    #[cfg(feature = "mcp-server")]
770    pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
771        self.with_storage_mut(|s| s.restore_from_connection(source))
772    }
773}
774
775/// The wasm, asynchronous facade over the wa-sqlite backend.
776///
777/// Mirrors the native facade method-for-method, but each call is `async` and
778/// awaits the shared action handler directly - there is no `block_on`, because
779/// the wasm backend's bridge calls genuinely suspend.
780///
781/// Calls on one instance are serialised: each holds an async mutex over the
782/// single wa-sqlite connection for the whole handler, so a transaction's
783/// begin..commit cannot interleave with another call, and concurrent callers
784/// (for example two awaited operations on one `WasmDatabase`) queue rather
785/// than deadlock. Because the mutex is async, queuing suspends instead of
786/// blocking the single-threaded runtime; because there is only ever one
787/// writer at a time, `BEGIN IMMEDIATE` cannot return `SQLITE_BUSY`.
788#[cfg(feature = "wasm-sqlite")]
789impl Database<WasmBridgeBackend> {
790    /// Open (or create) a wa-sqlite database persisted to OPFS under `name`.
791    pub async fn open(name: &str) -> Result<Self> {
792        let backend = WasmBridgeBackend::open(name)
793            .await
794            .map_err(DynoxideError::from)?;
795        Ok(Self {
796            inner: Arc::new(BackendMutex::new(backend)),
797            idempotency_tokens: Arc::new(Mutex::new(HashMap::new())),
798        })
799    }
800
801    /// Lock the single backend for the span of one handler call. The guard is
802    /// held across the whole call so the operation (including any transaction)
803    /// is atomic; the async mutex queues concurrent callers rather than
804    /// deadlocking, and never poisons.
805    async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
806        self.inner.lock().await
807    }
808
809    /// Create a new DynamoDB table.
810    pub async fn create_table(
811        &self,
812        request: actions::create_table::CreateTableRequest,
813    ) -> Result<actions::create_table::CreateTableResponse> {
814        let backend = self.backend().await;
815        actions::create_table::execute(&*backend, request).await
816    }
817
818    /// Delete a DynamoDB table.
819    pub async fn delete_table(
820        &self,
821        request: actions::delete_table::DeleteTableRequest,
822    ) -> Result<actions::delete_table::DeleteTableResponse> {
823        let backend = self.backend().await;
824        actions::delete_table::execute(&*backend, request).await
825    }
826
827    /// Describe a DynamoDB table.
828    pub async fn describe_table(
829        &self,
830        request: actions::describe_table::DescribeTableRequest,
831    ) -> Result<actions::describe_table::DescribeTableResponse> {
832        let backend = self.backend().await;
833        actions::describe_table::execute(&*backend, request).await
834    }
835
836    /// List DynamoDB tables.
837    pub async fn list_tables(
838        &self,
839        request: actions::list_tables::ListTablesRequest,
840    ) -> Result<actions::list_tables::ListTablesResponse> {
841        let backend = self.backend().await;
842        actions::list_tables::execute(&*backend, request).await
843    }
844
845    /// Put an item into a DynamoDB table.
846    pub async fn put_item(
847        &self,
848        request: actions::put_item::PutItemRequest,
849    ) -> Result<actions::put_item::PutItemResponse> {
850        let backend = self.backend().await;
851        actions::put_item::execute(&*backend, request).await
852    }
853
854    /// Get an item from a DynamoDB table.
855    pub async fn get_item(
856        &self,
857        request: actions::get_item::GetItemRequest,
858    ) -> Result<actions::get_item::GetItemResponse> {
859        let backend = self.backend().await;
860        actions::get_item::execute(&*backend, request).await
861    }
862
863    /// Delete an item from a DynamoDB table.
864    pub async fn delete_item(
865        &self,
866        request: actions::delete_item::DeleteItemRequest,
867    ) -> Result<actions::delete_item::DeleteItemResponse> {
868        let backend = self.backend().await;
869        actions::delete_item::execute(&*backend, request).await
870    }
871
872    /// Query a DynamoDB table or secondary index.
873    pub async fn query(
874        &self,
875        request: actions::query::QueryRequest,
876    ) -> Result<actions::query::QueryResponse> {
877        let backend = self.backend().await;
878        actions::query::execute(&*backend, request).await
879    }
880
881    /// Scan a DynamoDB table or secondary index.
882    pub async fn scan(
883        &self,
884        request: actions::scan::ScanRequest,
885    ) -> Result<actions::scan::ScanResponse> {
886        let backend = self.backend().await;
887        actions::scan::execute(&*backend, request).await
888    }
889}
890
891#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
892mod tests {
893    use super::*;
894
895    #[test]
896    fn test_database_memory() {
897        let db = Database::memory().unwrap();
898        // Should be able to clone (Arc)
899        let _db2 = db.clone();
900    }
901
902    #[test]
903    fn test_database_with_storage() {
904        let db = Database::memory().unwrap();
905        let tables = db.with_storage(|s| s.list_table_names()).unwrap();
906        assert!(tables.is_empty());
907    }
908
909    #[test]
910    fn test_database_thread_safe() {
911        let db = Database::memory().unwrap();
912        let db2 = db.clone();
913
914        let handle =
915            std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
916
917        let tables = handle.join().unwrap();
918        assert!(tables.is_empty());
919    }
920
921    #[test]
922    fn test_native_database_alias_round_trips() {
923        // The `NativeDatabase` alias is the default `Database<RusqliteBackend>`
924        // and must drive the async handlers through the synchronous facade
925        // transparently: a put/get round-trip behaves exactly as before.
926        let db: NativeDatabase = Database::memory().unwrap();
927
928        db.create_table(actions::create_table::CreateTableRequest {
929            table_name: "tbl".to_string(),
930            key_schema: vec![types::KeySchemaElement {
931                attribute_name: "pk".to_string(),
932                key_type: types::KeyType::HASH,
933            }],
934            attribute_definitions: vec![types::AttributeDefinition {
935                attribute_name: "pk".to_string(),
936                attribute_type: types::ScalarAttributeType::S,
937            }],
938            ..Default::default()
939        })
940        .unwrap();
941
942        let mut item = HashMap::new();
943        item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
944        db.put_item(actions::put_item::PutItemRequest {
945            table_name: "tbl".to_string(),
946            item,
947            ..Default::default()
948        })
949        .unwrap();
950
951        let mut key = HashMap::new();
952        key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
953        let got = db
954            .get_item(actions::get_item::GetItemRequest {
955                table_name: "tbl".to_string(),
956                key,
957                ..Default::default()
958            })
959            .unwrap();
960        assert_eq!(
961            got.item.unwrap().get("pk"),
962            Some(&AttributeValue::S("a".to_string()))
963        );
964    }
965}