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