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