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