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