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::{Duration, 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/// One idempotency slot: when the token was claimed, the request hash it was
118/// claimed with, and the response once the call finishes.
119///
120/// `None` in the response position means a call has claimed the token and is
121/// still running. Only the asynchronous driver writes that state; the
122/// synchronous one holds the cache lock across its call instead, so a slot it
123/// finds is always a finished one.
124type TokenSlot<T> = (Instant, u64, Option<T>);
125
126/// Idempotency cache keyed by `ClientRequestToken`.
127type TokenCache<T> = HashMap<String, TokenSlot<T>>;
128
129/// Cached `TransactWriteItems` responses.
130type TransactWriteTokenCache =
131    TokenCache<actions::transact_write_items::TransactWriteItemsResponse>;
132
133/// Cached `ExecuteTransaction` responses. Separate from
134/// [`TransactWriteTokenCache`] because the response type differs and
135/// `ClientRequestToken` idempotency is scoped per API operation in AWS: a token
136/// reused across `TransactWriteItems` and `ExecuteTransaction` executes once in
137/// each, so the two caches are independent by design.
138type ExecuteTransactionTokenCache =
139    TokenCache<actions::execute_transaction::ExecuteTransactionResponse>;
140
141/// The transactional idempotency caches one engine instance owns.
142///
143/// Opaque by design: the slot shape is an implementation detail. Construct one
144/// with [`TokenCaches::new`] and lend it to a dispatch that needs it.
145#[derive(Default)]
146pub struct TokenCaches {
147    // Read by the native transactional path; a backend-neutral build has no
148    // such caller yet, so scope the exemption to exactly that configuration
149    // rather than blanket-allowing it.
150    #[cfg_attr(
151        not(any(feature = "native-sqlite", feature = "_has-encryption")),
152        allow(dead_code)
153    )]
154    transact_write: Mutex<TransactWriteTokenCache>,
155    execute_transaction: Mutex<ExecuteTransactionTokenCache>,
156}
157
158impl TokenCaches {
159    /// An empty set of caches.
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    #[cfg(any(feature = "wasm-sqlite", test))]
165    pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
166        &self.execute_transaction
167    }
168}
169
170/// AWS caps `ClientRequestToken` at 36 characters.
171const MAX_TOKEN_LEN: usize = 36;
172
173/// AWS scopes transactional idempotency to a 10-minute window. Entries older
174/// than this are evicted on the next token-bearing call.
175const TOKEN_EXPIRY_SECS: u64 = 600;
176
177/// Reject a token longer than DynamoDB accepts, with its exact message.
178fn validate_token(token: Option<&str>) -> Result<()> {
179    match token {
180        Some(token) if token.len() > MAX_TOKEN_LEN => {
181            Err(DynoxideError::ValidationException(format!(
182                "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
183            )))
184        }
185        _ => Ok(()),
186    }
187}
188
189/// Hash the idempotency key material.
190///
191/// The input is the items or statements only, never `ReturnConsumedCapacity`,
192/// so a same-token call differing only in the capacity mode replays rather than
193/// mismatching. Normalised through `serde_json::Value` first so the digest does
194/// not depend on map iteration order.
195fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
196    use std::hash::{Hash, Hasher};
197    let normalised = serde_json::to_value(input)
198        .and_then(|v| serde_json::to_vec(&v))
199        .unwrap_or_default();
200    let mut hasher = std::collections::hash_map::DefaultHasher::new();
201    normalised.hash(&mut hasher);
202    hasher.finish()
203}
204
205fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
206    cache
207        .lock()
208        .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
209}
210
211/// Drop entries older than `window`.
212///
213/// The window is a parameter so a test can drive expiry without back-dating an
214/// `Instant`, which is not always possible: `Instant`'s origin is boot, so
215/// subtracting ten minutes fails on a machine that has been up for less.
216fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
217    cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
218}
219
220/// The expiry window every caller outside the tests uses.
221fn token_window() -> Duration {
222    Duration::from_secs(TOKEN_EXPIRY_SECS)
223}
224
225/// Run a transactional operation with `ClientRequestToken` idempotency, shared
226/// by [`Database::transact_write_items`] and [`Database::execute_transaction`].
227///
228/// The cache lock is held across the whole first call (check, execute, insert)
229/// so two concurrent same-token calls cannot both execute: the second
230/// serialises behind the first and replays. That hold is the exclusion, which
231/// is why this driver never needs to claim a slot the way the asynchronous one
232/// does. A cache hit clones the stored response, releases the lock, then
233/// rebuilds the reply via `replay` (which re-derives read capacity).
234///
235/// Lock ordering is cache-then-storage: `execute` takes the storage lock
236/// second, and nothing takes storage first and then a token cache, so there is
237/// no reverse path to deadlock against. Any future code touching both locks
238/// must keep this order.
239///
240/// A failed `execute` is propagated without caching, so a same-token retry
241/// re-executes.
242#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
243fn run_idempotent<T, H, E, R>(
244    cache: &Mutex<TokenCache<T>>,
245    token: Option<&str>,
246    hash_input: &H,
247    execute: E,
248    replay: R,
249) -> Result<T>
250where
251    T: Clone,
252    H: serde::Serialize,
253    E: FnOnce() -> Result<T>,
254    R: FnOnce(&T) -> T,
255{
256    validate_token(token)?;
257
258    // No idempotency token: execute without touching the cache.
259    let Some(token) = token else {
260        return execute();
261    };
262    let hash = request_hash(hash_input);
263
264    let mut cache = lock_cache(cache)?;
265    evict_expired(&mut cache, token_window());
266    let cached = match cache.get(token) {
267        Some((_, cached_hash, _)) if *cached_hash != hash => {
268            return Err(DynoxideError::IdempotentParameterMismatchException(
269                "An error occurred (IdempotentParameterMismatchException)".to_string(),
270            ));
271        }
272        // A claimed-but-unfinished slot is treated as absent: this driver
273        // holds its lock across the call, so it never writes one and never
274        // shares a cache with the driver that does.
275        Some((_, _, slot)) => slot.clone(),
276        None => None,
277    };
278    if let Some(cached) = cached {
279        // Clone the cached response, release the lock, then rebuild the reply.
280        drop(cache);
281        return Ok(replay(&cached));
282    }
283    // Cache miss: execute and record the result while still holding the lock,
284    // so a concurrent same-token call waits and then replays rather than
285    // executing the transaction a second time.
286    let resp = execute()?;
287    cache.insert(
288        token.to_string(),
289        (Instant::now(), hash, Some(resp.clone())),
290    );
291    Ok(resp)
292}
293
294/// What a refused caller is told. Unreachable from any shipped surface today,
295/// so it stands for "something upstream stopped serialising callers".
296#[cfg(any(feature = "wasm-sqlite", test))]
297const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
298
299/// What a caller found when it tried to claim a token slot.
300#[cfg(any(feature = "wasm-sqlite", test))]
301enum TokenClaim<T> {
302    /// The slot was free and now belongs to this call, which must either
303    /// complete it or clear it. Carries the stamp that identifies the claim,
304    /// so a settle can tell its own slot from one a later call has taken over.
305    Marked(Instant),
306    /// Another call holds the slot and has not finished.
307    InFlight,
308    /// A call under this token used different request material.
309    Mismatch,
310    /// A finished call under this token; replay its response.
311    Hit(T),
312}
313
314/// Look up a token and, when it is free, claim it - under one lock.
315///
316/// The lookup and the claim cannot be separate acquisitions: two callers would
317/// both see a free slot and both proceed, which is the race the claim exists to
318/// prevent.
319#[cfg(any(feature = "wasm-sqlite", test))]
320fn lookup_or_claim<T: Clone>(
321    cache: &Mutex<TokenCache<T>>,
322    token: &str,
323    hash: u64,
324) -> Result<TokenClaim<T>> {
325    lookup_or_claim_within(cache, token, hash, token_window())
326}
327
328/// [`lookup_or_claim`] with an explicit expiry window, so a test can drive
329/// expiry without depending on how long the machine has been running.
330#[cfg(any(feature = "wasm-sqlite", test))]
331fn lookup_or_claim_within<T: Clone>(
332    cache: &Mutex<TokenCache<T>>,
333    token: &str,
334    hash: u64,
335    window: Duration,
336) -> Result<TokenClaim<T>> {
337    let mut cache = lock_cache(cache)?;
338    evict_expired(&mut cache, window);
339    Ok(match cache.get(token) {
340        Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
341        Some((_, _, None)) => TokenClaim::InFlight,
342        Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
343        None => {
344            let claimed_at = Instant::now();
345            cache.insert(token.to_string(), (claimed_at, hash, None));
346            TokenClaim::Marked(claimed_at)
347        }
348    })
349}
350
351/// Is this slot still the claim `claimed_at` made?
352///
353/// A claim can expire while its call is in flight, and a later call can then
354/// take the token over. The original call must not settle or release a slot
355/// that is no longer its own, or it would overwrite the newcomer's response or
356/// free a claim still being worked on.
357#[cfg(any(feature = "wasm-sqlite", test))]
358fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
359    matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
360}
361
362/// Settle a claimed slot with the response its call produced.
363#[cfg(any(feature = "wasm-sqlite", test))]
364fn record_complete<T>(
365    cache: &Mutex<TokenCache<T>>,
366    token: &str,
367    claimed_at: Instant,
368    hash: u64,
369    resp: T,
370) -> Result<()> {
371    let mut cache = lock_cache(cache)?;
372    if still_ours(&cache, token, claimed_at) {
373        cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
374    }
375    Ok(())
376}
377
378/// Release a claimed slot whose call failed, so a retry re-executes rather than
379/// replaying the failure.
380#[cfg(any(feature = "wasm-sqlite", test))]
381fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
382    let mut cache = lock_cache(cache)?;
383    if still_ours(&cache, token, claimed_at) {
384        cache.remove(token);
385    }
386    Ok(())
387}
388
389/// [`run_idempotent`] for a caller that cannot block.
390///
391/// Takes a future rather than a closure, because the wasm engine awaits real
392/// bridge promises. That rules out holding the cache lock across the call, so
393/// exclusion comes from claiming the token slot up front instead: a concurrent
394/// same-token caller finds the claim and is refused rather than starting a
395/// second execution.
396///
397/// A refused caller currently gets an internal error. No shipped surface can
398/// reach it - the engine serialises callers on the backend lock before either
399/// arrives here - so it stands for "something upstream stopped serialising"
400/// until an operation exists that can genuinely produce it.
401#[cfg(any(feature = "wasm-sqlite", test))]
402async fn run_idempotent_async<T, H, F, R>(
403    cache: &Mutex<TokenCache<T>>,
404    token: Option<&str>,
405    hash_input: &H,
406    execute: F,
407    replay: R,
408) -> Result<T>
409where
410    T: Clone,
411    H: serde::Serialize,
412    F: std::future::Future<Output = Result<T>>,
413    R: FnOnce(&T) -> T,
414{
415    validate_token(token)?;
416
417    let Some(token) = token else {
418        return execute.await;
419    };
420    let hash = request_hash(hash_input);
421
422    let claimed_at = match lookup_or_claim(cache, token, hash)? {
423        TokenClaim::Hit(cached) => return Ok(replay(&cached)),
424        TokenClaim::Mismatch => {
425            return Err(DynoxideError::IdempotentParameterMismatchException(
426                "An error occurred (IdempotentParameterMismatchException)".to_string(),
427            ));
428        }
429        TokenClaim::InFlight => {
430            return Err(DynoxideError::InternalServerError(
431                IN_FLIGHT_MESSAGE.to_string(),
432            ));
433        }
434        TokenClaim::Marked(claimed_at) => claimed_at,
435    };
436
437    match execute.await {
438        Ok(resp) => {
439            // The work has committed by this point, so a bookkeeping failure
440            // must not be reported as a failed call. The worst it costs is a
441            // replay: the slot stays claimed until it expires.
442            let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
443            Ok(resp)
444        }
445        Err(e) => {
446            let _ = clear_claim(cache, token, claimed_at);
447            Err(e)
448        }
449    }
450}
451
452/// The native storage backend: the rusqlite-backed [`storage::Storage`].
453///
454/// `Database`'s type parameter defaults to this, so existing native callers
455/// keep writing `Database` and get the synchronous rusqlite-backed engine.
456#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
457pub type RusqliteBackend = storage::Storage;
458
459/// The native, synchronous `Database`.
460///
461/// Alias for the default [`Database`] monomorphisation over
462/// [`RusqliteBackend`]. It exposes the historical synchronous public API
463/// unchanged: each method drives an async handler future to completion with
464/// `block_on`. Because the native backend's futures never suspend, that
465/// `block_on` never parks the thread.
466#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
467pub type NativeDatabase = Database<RusqliteBackend>;
468
469/// The wasm, asynchronous `Database` over the wasm SQLite backend.
470///
471/// Alias for [`Database`] monomorphised over [`WasmBridgeBackend`]. Unlike
472/// [`NativeDatabase`], its methods are `async fn` and never call `block_on`:
473/// the wasm backend awaits real SQLite-bridge promises, and the wasm main thread
474/// must not block.
475#[cfg(feature = "wasm-sqlite")]
476pub type WasmDatabase = Database<WasmBridgeBackend>;
477
478/// Build-visible preview marker for the wasm-sqlite backend.
479///
480/// `true` when built with `--features wasm-sqlite`, `false` otherwise. The wasm
481/// backend covers CRUD, query, scan, GSI/LSI, and PartiQL, and passes the
482/// conformance cases for all of them, but it still leaves several operations
483/// unimplemented. Consumers can read this constant to tell whether the artifact
484/// they hold is the fully conformant native build or the wasm preview.
485#[cfg(feature = "wasm-sqlite")]
486pub const WASM_PREVIEW: bool = true;
487/// Build-visible preview marker for the wasm-sqlite backend. See the
488/// `wasm-sqlite` variant for details.
489#[cfg(not(feature = "wasm-sqlite"))]
490pub const WASM_PREVIEW: bool = false;
491
492/// The main entry point for the DynamoDB emulator.
493///
494/// Generic over the storage backend `S`, monomorphised (no `dyn`). The type
495/// parameter defaults to [`RusqliteBackend`], so `Database` means the native
496/// engine and the public synchronous API is preserved via [`NativeDatabase`].
497///
498/// Wraps a storage layer and provides DynamoDB-compatible operations.
499/// Thread-safe via `Arc<Mutex<>>`, so clone freely across threads.
500#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
501pub struct Database<S = RusqliteBackend> {
502    inner: Arc<Mutex<S>>,
503    tokens: Arc<TokenCaches>,
504}
505
506/// Serialises backend access on the backend-neutral build. On wasm this is an
507/// async mutex: the bridge calls genuinely suspend, so a std mutex held across
508/// them would deadlock concurrent callers on the single-threaded runtime,
509/// whereas an async mutex queues them. Off wasm (the degenerate no-backend
510/// shell, which can never construct a `Database`) a std mutex stands in.
511#[cfg(all(
512    not(any(feature = "native-sqlite", feature = "_has-encryption")),
513    feature = "wasm-sqlite"
514))]
515use async_lock::Mutex as BackendMutex;
516#[cfg(all(
517    not(any(feature = "native-sqlite", feature = "_has-encryption")),
518    not(feature = "wasm-sqlite")
519))]
520use std::sync::Mutex as BackendMutex;
521
522/// The main entry point for the DynamoDB emulator (backend-neutral build).
523///
524/// On a build with no native backend (for example the `wasm-sqlite` build)
525/// there is no native default, so the backend must be named explicitly - for
526/// example `Database<WasmBridgeBackend>`, aliased as `WasmDatabase`.
527///
528/// Wraps a storage layer and provides DynamoDB-compatible operations. Backend
529/// access is serialised by [`BackendMutex`] (an async mutex on wasm); clone
530/// freely, only the `Arc`s are copied.
531#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
532pub struct Database<S> {
533    inner: Arc<BackendMutex<S>>,
534    tokens: Arc<TokenCaches>,
535}
536
537// Hand-written so cloning never requires `S: Clone`; only the `Arc`s clone.
538impl<S> Clone for Database<S> {
539    fn clone(&self) -> Self {
540        Self {
541            inner: Arc::clone(&self.inner),
542            tokens: Arc::clone(&self.tokens),
543        }
544    }
545}
546
547#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
548impl Database<RusqliteBackend> {
549    /// Open a persistent database at the given path.
550    pub fn new(path: &str) -> Result<Self> {
551        let storage = storage::Storage::new(path)?;
552        Ok(Self {
553            inner: Arc::new(Mutex::new(storage)),
554            tokens: Arc::new(TokenCaches::new()),
555        })
556    }
557
558    /// Open or create an encrypted database at the given path.
559    ///
560    /// The key must be a 64-character hex string representing a 32-byte key.
561    /// Example: `"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"`
562    ///
563    /// The key is passed to SQLCipher via `PRAGMA key`. The database file is
564    /// encrypted at rest using AES-256-CBC.
565    ///
566    /// # Security
567    ///
568    /// This function borrows the key as `&str` and cannot zeroize the caller's
569    /// copy. The caller is responsible for zeroizing owned key material after
570    /// this call returns (e.g., by using `zeroize::Zeroizing<String>`).
571    ///
572    /// # Errors
573    ///
574    /// Returns an error if:
575    /// - The key format is invalid (not 64 hex characters)
576    /// - The database exists but was created without encryption
577    /// - The database exists but the key is wrong
578    #[cfg(feature = "_has-encryption")]
579    pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
580        if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
581            return Err(DynoxideError::ValidationException(
582                "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
583            ));
584        }
585
586        let storage = storage::Storage::new_encrypted(path, key)?;
587        Ok(Self {
588            inner: Arc::new(Mutex::new(storage)),
589            tokens: Arc::new(TokenCaches::new()),
590        })
591    }
592
593    /// Open an in-memory database (for tests and ephemeral use).
594    pub fn memory() -> Result<Self> {
595        let storage = storage::Storage::memory()?;
596        Ok(Self {
597            inner: Arc::new(Mutex::new(storage)),
598            tokens: Arc::new(TokenCaches::new()),
599        })
600    }
601
602    /// Execute a closure with exclusive access to the storage layer.
603    pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
604    where
605        F: FnOnce(&storage::Storage) -> Result<T>,
606    {
607        let guard = self
608            .inner
609            .lock()
610            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
611        f(&guard)
612    }
613
614    /// Execute a closure with mutable exclusive access to the storage layer.
615    pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
616    where
617        F: FnOnce(&mut storage::Storage) -> Result<T>,
618    {
619        let mut guard = self
620            .inner
621            .lock()
622            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
623        f(&mut guard)
624    }
625
626    // -------------------------------------------------------------------
627    // Table operations
628    // -------------------------------------------------------------------
629
630    /// Create a new DynamoDB table.
631    pub fn create_table(
632        &self,
633        request: actions::create_table::CreateTableRequest,
634    ) -> Result<actions::create_table::CreateTableResponse> {
635        self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
636    }
637
638    /// Delete a DynamoDB table.
639    pub fn delete_table(
640        &self,
641        request: actions::delete_table::DeleteTableRequest,
642    ) -> Result<actions::delete_table::DeleteTableResponse> {
643        self.with_storage(|s| pollster::block_on(actions::delete_table::execute(s, request)))
644    }
645
646    /// Describe a DynamoDB table.
647    pub fn describe_table(
648        &self,
649        request: actions::describe_table::DescribeTableRequest,
650    ) -> Result<actions::describe_table::DescribeTableResponse> {
651        self.with_storage(|s| pollster::block_on(actions::describe_table::execute(s, request)))
652    }
653
654    /// Update a DynamoDB table (add/remove GSIs).
655    pub fn update_table(
656        &self,
657        request: actions::update_table::UpdateTableRequest,
658    ) -> Result<actions::update_table::UpdateTableResponse> {
659        self.with_storage(|s| pollster::block_on(actions::update_table::execute(s, request)))
660    }
661
662    /// List DynamoDB tables.
663    pub fn list_tables(
664        &self,
665        request: actions::list_tables::ListTablesRequest,
666    ) -> Result<actions::list_tables::ListTablesResponse> {
667        self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
668    }
669
670    // -------------------------------------------------------------------
671    // Tags
672    // -------------------------------------------------------------------
673
674    /// Add tags to a DynamoDB table.
675    pub fn tag_resource(
676        &self,
677        request: actions::tag_resource::TagResourceRequest,
678    ) -> Result<actions::tag_resource::TagResourceResponse> {
679        self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
680    }
681
682    /// Remove tags from a DynamoDB table.
683    pub fn untag_resource(
684        &self,
685        request: actions::untag_resource::UntagResourceRequest,
686    ) -> Result<actions::untag_resource::UntagResourceResponse> {
687        self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
688    }
689
690    /// List tags for a DynamoDB table.
691    pub fn list_tags_of_resource(
692        &self,
693        request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
694    ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
695        self.with_storage(|s| {
696            pollster::block_on(actions::list_tags_of_resource::execute(s, request))
697        })
698    }
699
700    // -------------------------------------------------------------------
701    // Item operations
702    // -------------------------------------------------------------------
703
704    /// Put an item into a DynamoDB table.
705    pub fn put_item(
706        &self,
707        request: actions::put_item::PutItemRequest,
708    ) -> Result<actions::put_item::PutItemResponse> {
709        self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
710    }
711
712    /// Get an item from a DynamoDB table.
713    pub fn get_item(
714        &self,
715        request: actions::get_item::GetItemRequest,
716    ) -> Result<actions::get_item::GetItemResponse> {
717        self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
718    }
719
720    /// Delete an item from a DynamoDB table.
721    pub fn delete_item(
722        &self,
723        request: actions::delete_item::DeleteItemRequest,
724    ) -> Result<actions::delete_item::DeleteItemResponse> {
725        self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
726    }
727
728    /// Update an item in a DynamoDB table.
729    pub fn update_item(
730        &self,
731        request: actions::update_item::UpdateItemRequest,
732    ) -> Result<actions::update_item::UpdateItemResponse> {
733        self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
734    }
735
736    // -------------------------------------------------------------------
737    // Batch operations
738    // -------------------------------------------------------------------
739
740    /// Batch get items from one or more DynamoDB tables.
741    pub fn batch_get_item(
742        &self,
743        request: actions::batch_get_item::BatchGetItemRequest,
744    ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
745        self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
746    }
747
748    /// Batch write items to one or more DynamoDB tables.
749    pub fn batch_write_item(
750        &self,
751        request: actions::batch_write_item::BatchWriteItemRequest,
752    ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
753        self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
754    }
755
756    /// Import items in bulk, bypassing per-item size validation.
757    ///
758    /// All items are inserted in a single transaction. If any item fails,
759    /// the entire import is rolled back. Items with duplicate keys within
760    /// the batch are resolved by last-write-wins (later items in the vec
761    /// overwrite earlier items with the same primary key).
762    ///
763    /// GSI entries are maintained: items with GSI key attributes are
764    /// inserted into the appropriate GSI tables. Items missing GSI key
765    /// attributes are silently omitted from the GSI (sparse GSI behavior,
766    /// matching DynamoDB semantics).
767    ///
768    /// Stream records are NOT generated by default. Use
769    /// `ImportOptions { record_streams: true, .. }` if stream recording is needed.
770    pub fn import_items(
771        &self,
772        table_name: &str,
773        items: Vec<Item>,
774        options: ImportOptions,
775    ) -> Result<ImportResult> {
776        self.with_storage(|s| {
777            pollster::block_on(actions::import_items::execute(
778                s, table_name, items, &options,
779            ))
780        })
781    }
782
783    /// Import items in bulk, skipping GSI DELETE-before-INSERT.
784    ///
785    /// Same as `import_items` but assumes the database is fresh (no
786    /// pre-existing rows), so GSI cleanup deletes are skipped entirely.
787    /// This eliminates the dominant bottleneck for large imports.
788    #[cfg(feature = "import")]
789    pub(crate) fn import_items_fresh(
790        &self,
791        table_name: &str,
792        items: Vec<Item>,
793        options: ImportOptions,
794    ) -> Result<ImportResult> {
795        self.with_storage(|s| {
796            pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
797                s, table_name, items, &options,
798            ))
799        })
800    }
801
802    // -------------------------------------------------------------------
803    // Bulk loading
804    // -------------------------------------------------------------------
805
806    /// Set aggressive SQLite PRAGMAs for bulk loading.
807    ///
808    /// Only safe when data loss on crash is acceptable (e.g., fresh import).
809    /// Call `disable_bulk_loading()` after the import to restore normal settings.
810    pub fn enable_bulk_loading(&self) -> Result<()> {
811        self.with_storage(|s| s.enable_bulk_loading())
812    }
813
814    /// Restore normal SQLite PRAGMAs after bulk loading.
815    pub fn disable_bulk_loading(&self) -> Result<()> {
816        self.with_storage(|s| s.disable_bulk_loading())
817    }
818
819    // -------------------------------------------------------------------
820    // Query & Scan
821    // -------------------------------------------------------------------
822
823    /// Query a DynamoDB table.
824    pub fn query(
825        &self,
826        request: actions::query::QueryRequest,
827    ) -> Result<actions::query::QueryResponse> {
828        self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
829    }
830
831    /// Scan a DynamoDB table.
832    pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
833        self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
834    }
835
836    // -------------------------------------------------------------------
837    // Transactions
838    // -------------------------------------------------------------------
839
840    /// Execute a transactional write (up to 100 actions, all-or-nothing).
841    ///
842    /// Honours `ClientRequestToken` idempotency via [`run_idempotent`]: a
843    /// same-token, same-items call within the expiry window replays the stored
844    /// result (reported as transactional read capacity) without re-applying the
845    /// writes.
846    pub fn transact_write_items(
847        &self,
848        request: actions::transact_write_items::TransactWriteItemsRequest,
849    ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
850        run_idempotent(
851            &self.tokens.transact_write,
852            request.client_request_token.as_deref(),
853            &request.transact_items,
854            || {
855                self.with_storage(|s| {
856                    pollster::block_on(actions::transact_write_items::execute(s, request.clone()))
857                })
858            },
859            |cached| {
860                // The replay recomputes a transactional read cost against the
861                // item sizes (4KB read granularity, diverging from the first
862                // call's 1KB-granular write above 1KB) and carries over the
863                // cached item collection metrics.
864                actions::transact_write_items::replay_response(
865                    &request.transact_items,
866                    &request.return_consumed_capacity,
867                    cached.item_collection_metrics.clone(),
868                )
869            },
870        )
871    }
872
873    /// Execute a transactional read (up to 100 gets).
874    pub fn transact_get_items(
875        &self,
876        request: actions::transact_get_items::TransactGetItemsRequest,
877    ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
878        self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
879    }
880
881    // -------------------------------------------------------------------
882    // Streams
883    // -------------------------------------------------------------------
884
885    /// List DynamoDB Streams.
886    pub fn list_streams(
887        &self,
888        request: actions::list_streams::ListStreamsRequest,
889    ) -> Result<actions::list_streams::ListStreamsResponse> {
890        self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
891    }
892
893    /// Describe a DynamoDB Stream.
894    pub fn describe_stream(
895        &self,
896        request: actions::describe_stream::DescribeStreamRequest,
897    ) -> Result<actions::describe_stream::DescribeStreamResponse> {
898        self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
899    }
900
901    /// Get a shard iterator.
902    pub fn get_shard_iterator(
903        &self,
904        request: actions::get_shard_iterator::GetShardIteratorRequest,
905    ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
906        self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
907    }
908
909    /// Get stream records.
910    pub fn get_records(
911        &self,
912        request: actions::get_records::GetRecordsRequest,
913    ) -> Result<actions::get_records::GetRecordsResponse> {
914        self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
915    }
916
917    // -------------------------------------------------------------------
918    // TTL
919    // -------------------------------------------------------------------
920
921    /// Update time to live configuration.
922    pub fn update_time_to_live(
923        &self,
924        request: actions::update_time_to_live::UpdateTimeToLiveRequest,
925    ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
926        self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
927    }
928
929    /// Describe time to live configuration.
930    pub fn describe_time_to_live(
931        &self,
932        request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
933    ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
934        self.with_storage(|s| {
935            pollster::block_on(actions::describe_time_to_live::execute(s, request))
936        })
937    }
938
939    /// Run a TTL sweep, deleting expired items from all TTL-enabled tables.
940    /// Returns the number of items deleted.
941    pub fn sweep_ttl(&self) -> Result<usize> {
942        self.with_storage(|s| pollster::block_on(ttl::sweep_expired_items(s)))
943    }
944
945    // -------------------------------------------------------------------
946    // PartiQL
947    // -------------------------------------------------------------------
948
949    /// Execute a single PartiQL statement.
950    pub fn execute_statement(
951        &self,
952        request: actions::execute_statement::ExecuteStatementRequest,
953    ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
954        self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
955    }
956
957    /// Execute PartiQL statements transactionally (all-or-nothing).
958    ///
959    /// Honours `ClientRequestToken` idempotency via [`run_idempotent`], the same
960    /// way as [`transact_write_items`](Self::transact_write_items): a same-token,
961    /// same-statements call within the expiry window replays the stored result
962    /// without re-applying the statements. The cache is separate from the
963    /// `TransactWriteItems` one (see [`ExecuteTransactionTokenCache`]).
964    pub fn execute_transaction(
965        &self,
966        request: actions::execute_transaction::ExecuteTransactionRequest,
967    ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
968        run_idempotent(
969            &self.tokens.execute_transaction,
970            request.client_request_token.as_deref(),
971            &request.transact_statements,
972            || {
973                self.with_storage(|s| {
974                    pollster::block_on(actions::execute_transaction::execute(s, request.clone()))
975                })
976            },
977            |cached| {
978                // The replay reports transactional read capacity and carries
979                // over the cached first-call responses.
980                actions::execute_transaction::replay_response(
981                    &request.transact_statements,
982                    &request.return_consumed_capacity,
983                    cached.responses.clone(),
984                )
985            },
986        )
987    }
988
989    /// Execute a batch of PartiQL statements.
990    pub fn batch_execute_statement(
991        &self,
992        request: actions::batch_execute_statement::BatchExecuteStatementRequest,
993    ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
994        self.with_storage(|s| {
995            pollster::block_on(actions::batch_execute_statement::execute(s, request))
996        })
997    }
998
999    // -------------------------------------------------------------------
1000    // Cache tracking
1001    // -------------------------------------------------------------------
1002
1003    /// Update the `cached_at` timestamp for a single item.
1004    ///
1005    /// Used by cache layers to track when items were last fetched from a
1006    /// remote source. The timestamp is a Unix epoch in seconds (f64).
1007    pub fn touch_cached_at(
1008        &self,
1009        table_name: &str,
1010        pk: &str,
1011        sk: &str,
1012        timestamp: f64,
1013    ) -> Result<()> {
1014        self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
1015    }
1016
1017    /// Get items ordered by `cached_at` (oldest first) for LRU eviction.
1018    ///
1019    /// Returns `(pk, sk, item_size)` tuples. Items with NULL `cached_at`
1020    /// are excluded (they were never cached from a remote source).
1021    pub fn get_lru_items(
1022        &self,
1023        table_name: &str,
1024        limit: usize,
1025    ) -> Result<Vec<(String, String, i64)>> {
1026        self.with_storage(|s| s.get_lru_items(table_name, limit))
1027    }
1028
1029    // -------------------------------------------------------------------
1030    // Introspection
1031    // -------------------------------------------------------------------
1032
1033    /// Get the database file path, or `None` for in-memory databases.
1034    pub fn db_path(&self) -> Result<Option<String>> {
1035        self.with_storage(|s| Ok(s.db_path()))
1036    }
1037
1038    /// Get the total database size in bytes.
1039    pub fn db_size_bytes(&self) -> Result<u64> {
1040        self.with_storage(|s| s.db_size_bytes())
1041    }
1042
1043    /// Count the number of DynamoDB tables.
1044    pub fn table_count(&self) -> Result<usize> {
1045        self.with_storage(|s| s.table_count())
1046    }
1047
1048    /// Get per-table statistics: name, item count, and approximate size in bytes.
1049    pub fn table_stats(&self) -> Result<Vec<TableStats>> {
1050        self.with_storage(|s| s.table_stats())
1051    }
1052
1053    /// Get metadata for a specific table (key schema, GSIs, TTL config, etc.).
1054    pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
1055        self.with_storage(|s| s.get_table_metadata(table_name))
1056    }
1057
1058    /// Get combined database info atomically in a single lock acquisition.
1059    ///
1060    /// Returns path, size, table count, and per-table stats + metadata.
1061    /// Avoids the consistency issues of calling individual methods separately.
1062    pub fn database_info(&self) -> Result<DatabaseInfo> {
1063        self.with_storage(|s| s.database_info())
1064    }
1065
1066    // -------------------------------------------------------------------
1067    // Snapshot operations
1068    // -------------------------------------------------------------------
1069
1070    /// Run VACUUM to compact the database file in place.
1071    pub fn vacuum(&self) -> Result<()> {
1072        self.with_storage(|s| s.vacuum())
1073    }
1074
1075    /// Create a snapshot of the database by copying it to the given path.
1076    ///
1077    /// Uses SQLite's `VACUUM INTO` which works for both in-memory and
1078    /// file-backed databases. The snapshot is a standalone SQLite file.
1079    pub fn vacuum_into(&self, path: &str) -> Result<()> {
1080        self.with_storage(|s| s.vacuum_into(path))
1081    }
1082
1083    /// Restore the database from a snapshot file.
1084    ///
1085    /// Uses SQLite's backup API to replace the current database contents
1086    /// with the snapshot. Works for both in-memory and file-backed databases.
1087    /// The backup is atomic — either all pages are copied or none are.
1088    pub fn restore_from(&self, path: &str) -> Result<()> {
1089        self.with_storage_mut(|s| s.restore_from(path))
1090    }
1091
1092    /// Backup the current database to a new in-memory SQLite connection.
1093    ///
1094    /// Returns an owned `Connection` holding a complete copy. Used for
1095    /// in-memory snapshot storage — no filesystem side-effects.
1096    #[cfg(feature = "mcp-server")]
1097    pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
1098        self.with_storage(|s| s.backup_to_memory())
1099    }
1100
1101    /// Restore the database from an in-memory SQLite connection.
1102    ///
1103    /// Replaces current contents with the source connection's data.
1104    #[cfg(feature = "mcp-server")]
1105    pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
1106        self.with_storage_mut(|s| s.restore_from_connection(source))
1107    }
1108}
1109
1110/// The wasm, asynchronous facade over the wasm SQLite backend.
1111///
1112/// Mirrors the native facade method-for-method, but each call is `async` and
1113/// awaits the shared action handler directly - there is no `block_on`, because
1114/// the wasm backend's bridge calls genuinely suspend.
1115///
1116/// Calls on one instance are serialised: each holds an async mutex over the
1117/// single SQLite connection for the whole handler, so a transaction's
1118/// begin..commit cannot interleave with another call, and concurrent callers
1119/// (for example two awaited operations on one `WasmDatabase`) queue rather
1120/// than deadlock. Because the mutex is async, queuing suspends instead of
1121/// blocking the single-threaded runtime; because there is only ever one
1122/// writer at a time, `BEGIN IMMEDIATE` cannot return `SQLITE_BUSY`.
1123#[cfg(feature = "wasm-sqlite")]
1124impl Database<WasmBridgeBackend> {
1125    /// Open (or create) a SQLite database persisted to OPFS under `name`,
1126    /// degrading to an ephemeral in-memory session where OPFS is unavailable.
1127    pub async fn open(name: &str) -> Result<Self> {
1128        Self::open_with(name, false).await
1129    }
1130
1131    /// Open as [`open`](Self::open), but force an ephemeral in-memory session
1132    /// when `ephemeral` is true.
1133    pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
1134        let backend = WasmBridgeBackend::open_with(name, ephemeral)
1135            .await
1136            .map_err(DynoxideError::from)?;
1137        Ok(Self {
1138            inner: Arc::new(BackendMutex::new(backend)),
1139            tokens: Arc::new(TokenCaches::new()),
1140        })
1141    }
1142
1143    /// The active persistence mode: `"opfs"`, `"memory"`, or `"unknown"`.
1144    pub async fn persistence_mode(&self) -> String {
1145        self.backend().await.persistence_mode().to_string()
1146    }
1147
1148    /// Close the underlying SQLite connection. The operation-level engine
1149    /// calls this before re-opening, so the previous connection is released
1150    /// rather than leaked when a new database replaces it.
1151    pub async fn close(&self) -> Result<()> {
1152        self.backend()
1153            .await
1154            .close()
1155            .await
1156            .map_err(DynoxideError::from)
1157    }
1158
1159    /// Lock the single backend for the span of one handler call. The guard is
1160    /// held across the whole call so the operation (including any transaction)
1161    /// is atomic; the async mutex queues concurrent callers rather than
1162    /// deadlocking, and never poisons.
1163    ///
1164    /// `pub(crate)` so the operation-level [`wasm_api`](crate::wasm_api) engine
1165    /// can hold the lock across a whole `execute` dispatch, matching the
1166    /// per-handler atomicity of the wrappers below.
1167    pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
1168        self.inner.lock().await
1169    }
1170
1171    /// The idempotency caches this instance owns, for a dispatch that has to
1172    /// honour `ClientRequestToken`. Outlives any single call, so a replay finds
1173    /// the earlier one's result.
1174    pub(crate) fn token_caches(&self) -> &TokenCaches {
1175        &self.tokens
1176    }
1177
1178    /// Create a new DynamoDB table.
1179    pub async fn create_table(
1180        &self,
1181        request: actions::create_table::CreateTableRequest,
1182    ) -> Result<actions::create_table::CreateTableResponse> {
1183        let backend = self.backend().await;
1184        actions::create_table::execute(&*backend, request).await
1185    }
1186
1187    /// Delete a DynamoDB table.
1188    pub async fn delete_table(
1189        &self,
1190        request: actions::delete_table::DeleteTableRequest,
1191    ) -> Result<actions::delete_table::DeleteTableResponse> {
1192        let backend = self.backend().await;
1193        actions::delete_table::execute(&*backend, request).await
1194    }
1195
1196    /// Describe a DynamoDB table.
1197    pub async fn describe_table(
1198        &self,
1199        request: actions::describe_table::DescribeTableRequest,
1200    ) -> Result<actions::describe_table::DescribeTableResponse> {
1201        let backend = self.backend().await;
1202        actions::describe_table::execute(&*backend, request).await
1203    }
1204
1205    /// List DynamoDB tables.
1206    pub async fn list_tables(
1207        &self,
1208        request: actions::list_tables::ListTablesRequest,
1209    ) -> Result<actions::list_tables::ListTablesResponse> {
1210        let backend = self.backend().await;
1211        actions::list_tables::execute(&*backend, request).await
1212    }
1213
1214    /// Put an item into a DynamoDB table.
1215    pub async fn put_item(
1216        &self,
1217        request: actions::put_item::PutItemRequest,
1218    ) -> Result<actions::put_item::PutItemResponse> {
1219        let backend = self.backend().await;
1220        actions::put_item::execute(&*backend, request).await
1221    }
1222
1223    /// Get an item from a DynamoDB table.
1224    pub async fn get_item(
1225        &self,
1226        request: actions::get_item::GetItemRequest,
1227    ) -> Result<actions::get_item::GetItemResponse> {
1228        let backend = self.backend().await;
1229        actions::get_item::execute(&*backend, request).await
1230    }
1231
1232    /// Delete an item from a DynamoDB table.
1233    pub async fn delete_item(
1234        &self,
1235        request: actions::delete_item::DeleteItemRequest,
1236    ) -> Result<actions::delete_item::DeleteItemResponse> {
1237        let backend = self.backend().await;
1238        actions::delete_item::execute(&*backend, request).await
1239    }
1240
1241    /// Query a DynamoDB table or secondary index.
1242    pub async fn query(
1243        &self,
1244        request: actions::query::QueryRequest,
1245    ) -> Result<actions::query::QueryResponse> {
1246        let backend = self.backend().await;
1247        actions::query::execute(&*backend, request).await
1248    }
1249
1250    /// Scan a DynamoDB table or secondary index.
1251    pub async fn scan(
1252        &self,
1253        request: actions::scan::ScanRequest,
1254    ) -> Result<actions::scan::ScanResponse> {
1255        let backend = self.backend().await;
1256        actions::scan::execute(&*backend, request).await
1257    }
1258}
1259
1260#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1261mod tests {
1262    use super::*;
1263
1264    #[test]
1265    fn test_database_memory() {
1266        let db = Database::memory().unwrap();
1267        // Should be able to clone (Arc)
1268        let _db2 = db.clone();
1269    }
1270
1271    #[test]
1272    fn test_database_with_storage() {
1273        let db = Database::memory().unwrap();
1274        let tables = db.with_storage(|s| s.list_table_names()).unwrap();
1275        assert!(tables.is_empty());
1276    }
1277
1278    #[test]
1279    fn test_database_thread_safe() {
1280        let db = Database::memory().unwrap();
1281        let db2 = db.clone();
1282
1283        let handle =
1284            std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
1285
1286        let tables = handle.join().unwrap();
1287        assert!(tables.is_empty());
1288    }
1289
1290    #[test]
1291    fn test_native_database_alias_round_trips() {
1292        // The `NativeDatabase` alias is the default `Database<RusqliteBackend>`
1293        // and must drive the async handlers through the synchronous facade
1294        // transparently: a put/get round-trip behaves exactly as before.
1295        let db: NativeDatabase = Database::memory().unwrap();
1296
1297        db.create_table(actions::create_table::CreateTableRequest {
1298            table_name: "tbl".to_string(),
1299            key_schema: vec![types::KeySchemaElement {
1300                attribute_name: "pk".to_string(),
1301                key_type: types::KeyType::HASH,
1302            }],
1303            attribute_definitions: vec![types::AttributeDefinition {
1304                attribute_name: "pk".to_string(),
1305                attribute_type: types::ScalarAttributeType::S,
1306            }],
1307            ..Default::default()
1308        })
1309        .unwrap();
1310
1311        let mut item = HashMap::new();
1312        item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1313        db.put_item(actions::put_item::PutItemRequest {
1314            table_name: "tbl".to_string(),
1315            item,
1316            ..Default::default()
1317        })
1318        .unwrap();
1319
1320        let mut key = HashMap::new();
1321        key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1322        let got = db
1323            .get_item(actions::get_item::GetItemRequest {
1324                table_name: "tbl".to_string(),
1325                key,
1326                ..Default::default()
1327            })
1328            .unwrap();
1329        assert_eq!(
1330            got.item.unwrap().get("pk"),
1331            Some(&AttributeValue::S("a".to_string()))
1332        );
1333    }
1334}
1335
1336/// Cache primitives and the asynchronous driver.
1337///
1338/// These are private, so they are exercised here rather than from an
1339/// integration test. The public-facade behaviour both drivers must preserve
1340/// lives in `tests/execute_transaction.rs` and `tests/transactions.rs`.
1341#[cfg(test)]
1342mod idempotency_tests {
1343    use super::*;
1344    use std::cell::Cell;
1345
1346    const KEY: &str = "statements";
1347    const TOKEN: &str = "tok";
1348
1349    fn cache() -> Mutex<TokenCache<u32>> {
1350        Mutex::new(HashMap::new())
1351    }
1352
1353    fn hash() -> u64 {
1354        request_hash(&KEY)
1355    }
1356
1357    fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
1358    where
1359        F: std::future::Future<Output = Result<u32>>,
1360    {
1361        pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
1362    }
1363
1364    #[test]
1365    fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
1366        let cache = cache();
1367
1368        let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1369            panic!("the first caller should have claimed the token");
1370        };
1371        assert!(matches!(
1372            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1373            TokenClaim::InFlight
1374        ));
1375
1376        record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1377        assert!(matches!(
1378            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1379            TokenClaim::Hit(7)
1380        ));
1381    }
1382
1383    #[test]
1384    fn only_one_caller_can_claim_a_token() {
1385        // Split the lookup and the claim into two acquisitions and this goes
1386        // red: several racing callers would each find the slot free. Sequential
1387        // calls would not catch that, so this has to be threaded.
1388        use std::sync::Barrier;
1389
1390        const N: usize = 16;
1391        let cache = cache();
1392        let barrier = Barrier::new(N);
1393
1394        let marked = std::thread::scope(|scope| {
1395            let handles: Vec<_> = (0..N)
1396                .map(|_| {
1397                    scope.spawn(|| {
1398                        barrier.wait();
1399                        matches!(
1400                            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1401                            TokenClaim::Marked(_)
1402                        )
1403                    })
1404                })
1405                .collect();
1406            handles
1407                .into_iter()
1408                .map(|h| h.join().unwrap())
1409                .filter(|claimed| *claimed)
1410                .count()
1411        });
1412
1413        assert_eq!(marked, 1);
1414    }
1415
1416    #[test]
1417    fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
1418        // A claim can expire while its call is still running, letting a second
1419        // caller take the token. The first must not then settle or release a
1420        // slot that is no longer its own.
1421        let cache = cache();
1422        let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1423            panic!("the first caller should have claimed the token");
1424        };
1425        let TokenClaim::Marked(second) =
1426            lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
1427        else {
1428            panic!("the expired claim should have been re-issued");
1429        };
1430        assert_ne!(first, second);
1431
1432        // The first call finishing must not overwrite the second's slot.
1433        record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
1434        assert!(matches!(
1435            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1436            TokenClaim::InFlight
1437        ));
1438
1439        // Nor must the first call failing release it.
1440        clear_claim(&cache, TOKEN, first).unwrap();
1441        assert!(matches!(
1442            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1443            TokenClaim::InFlight
1444        ));
1445
1446        // The caller that owns the claim still settles it.
1447        record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
1448        assert!(matches!(
1449            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1450            TokenClaim::Hit(2)
1451        ));
1452    }
1453
1454    #[test]
1455    fn a_different_request_under_the_same_token_mismatches_in_both_states() {
1456        let other = request_hash(&"different");
1457
1458        let cache = cache();
1459        let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1460            panic!("expected a free slot to claim");
1461        };
1462        assert!(matches!(
1463            lookup_or_claim(&cache, TOKEN, other).unwrap(),
1464            TokenClaim::Mismatch
1465        ));
1466
1467        record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1468        assert!(matches!(
1469            lookup_or_claim(&cache, TOKEN, other).unwrap(),
1470            TokenClaim::Mismatch
1471        ));
1472    }
1473
1474    #[test]
1475    fn the_claim_lands_before_the_call_is_polled() {
1476        // Swapping the claim and the poll in the driver turns this red.
1477        let cache = cache();
1478        let claimed_first = Cell::new(false);
1479
1480        let out = drive(&cache, Some(TOKEN), async {
1481            // try_lock, not lock: a driver still holding the guard across the
1482            // call would hang here, and a hang reads worse than an assertion.
1483            let slots = cache
1484                .try_lock()
1485                .expect("the cache lock must be released before the call runs");
1486            claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
1487            Ok(7)
1488        });
1489
1490        assert_eq!(out.unwrap(), 7);
1491        assert!(claimed_first.get());
1492    }
1493
1494    #[test]
1495    fn a_caller_arriving_under_a_live_claim_does_not_execute() {
1496        let cache = cache();
1497        let second_ran = Cell::new(false);
1498
1499        let out = drive(&cache, Some(TOKEN), async {
1500            let second = drive(&cache, Some(TOKEN), async {
1501                second_ran.set(true);
1502                Ok(0)
1503            });
1504            // Pinned exactly: InternalServerError is also what a poisoned lock
1505            // produces, so the variant alone would not tell them apart.
1506            assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
1507            // The refusal must leave the claim alone; releasing it here would
1508            // let a third caller start a second execution.
1509            assert!(matches!(
1510                lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1511                TokenClaim::InFlight
1512            ));
1513            Ok(1)
1514        });
1515
1516        assert_eq!(out.unwrap(), 1);
1517        assert!(!second_ran.get(), "the second call must not run the work");
1518        // And the outer call still settles the slot it owns.
1519        assert!(matches!(
1520            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1521            TokenClaim::Hit(1)
1522        ));
1523    }
1524
1525    #[test]
1526    fn the_synchronous_driver_treats_a_live_claim_as_absent() {
1527        // The two drivers never share a cache today, so this cannot happen -
1528        // the synchronous driver holds its lock across the call and never
1529        // writes a claim. Pinned so that if they ever do share one, the
1530        // double-apply shows up here rather than in production.
1531        let cache = cache();
1532        lookup_or_claim(&cache, TOKEN, hash()).unwrap();
1533
1534        let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
1535        assert_eq!(
1536            out, 9,
1537            "a claimed slot is not treated as a replayable result"
1538        );
1539    }
1540
1541    #[test]
1542    fn a_settled_token_replays_without_re_executing() {
1543        let cache = cache();
1544        let runs = Cell::new(0);
1545
1546        for _ in 0..2 {
1547            let out = drive(&cache, Some(TOKEN), async {
1548                runs.set(runs.get() + 1);
1549                Ok(7)
1550            });
1551            assert_eq!(out.unwrap(), 7);
1552        }
1553        assert_eq!(runs.get(), 1);
1554    }
1555
1556    #[test]
1557    fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
1558        let cache = cache();
1559        let runs = Cell::new(0);
1560
1561        let first = drive(&cache, Some(TOKEN), async {
1562            runs.set(runs.get() + 1);
1563            Err(DynoxideError::ValidationException("no".into()))
1564        });
1565        assert!(first.is_err());
1566        assert!(
1567            cache.lock().unwrap().is_empty(),
1568            "the claim must be released"
1569        );
1570
1571        let second = drive(&cache, Some(TOKEN), async {
1572            runs.set(runs.get() + 1);
1573            Ok(7)
1574        });
1575        assert_eq!(second.unwrap(), 7);
1576        assert_eq!(runs.get(), 2);
1577    }
1578
1579    #[test]
1580    fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
1581        // A dropped future leaves its claim behind. Expiry is what stops that
1582        // wedging the token until the process restarts.
1583        let cache = cache();
1584        lookup_or_claim(&cache, TOKEN, hash()).unwrap();
1585
1586        assert!(matches!(
1587            lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
1588            TokenClaim::Marked(_)
1589        ));
1590    }
1591
1592    #[test]
1593    fn a_settled_token_stops_replaying_once_it_expires() {
1594        let cache = cache();
1595        let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1596            panic!("expected a free slot to claim");
1597        };
1598        record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1599
1600        // Inside the window it still replays.
1601        assert!(matches!(
1602            lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
1603            TokenClaim::Hit(7)
1604        ));
1605        // Past it, the token is free again.
1606        assert!(matches!(
1607            lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
1608            TokenClaim::Marked(_)
1609        ));
1610    }
1611
1612    #[test]
1613    fn concurrent_callers_under_one_token_execute_the_work_once() {
1614        // The asynchronous driver exists because it cannot hold a lock across
1615        // its call, so this is the property that matters most for it. The
1616        // synchronous driver has the same test in tests/execute_transaction.rs.
1617        use std::sync::Barrier;
1618        use std::sync::atomic::{AtomicUsize, Ordering};
1619
1620        const N: usize = 16;
1621        let cache = cache();
1622        let runs = AtomicUsize::new(0);
1623        let barrier = Barrier::new(N);
1624
1625        let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
1626            let handles: Vec<_> = (0..N)
1627                .map(|_| {
1628                    scope.spawn(|| {
1629                        barrier.wait();
1630                        drive(&cache, Some(TOKEN), async {
1631                            runs.fetch_add(1, Ordering::SeqCst);
1632                            Ok(7)
1633                        })
1634                    })
1635                })
1636                .collect();
1637            handles.into_iter().map(|h| h.join().unwrap()).collect()
1638        });
1639
1640        assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
1641        // Every caller either gets the value or is told a call is in flight;
1642        // none may run the work a second time.
1643        for outcome in outcomes {
1644            match outcome {
1645                Ok(v) => assert_eq!(v, 7),
1646                Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
1647            }
1648        }
1649    }
1650
1651    #[test]
1652    fn an_overlong_token_is_rejected_with_dynamodbs_message() {
1653        let token = "x".repeat(MAX_TOKEN_LEN + 1);
1654        let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
1655        assert_eq!(
1656            err.to_string(),
1657            format!(
1658                "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
1659            )
1660        );
1661    }
1662
1663    #[test]
1664    fn a_tokenless_call_never_touches_the_cache() {
1665        let cache = cache();
1666        let runs = Cell::new(0);
1667
1668        for _ in 0..2 {
1669            drive(&cache, None, async {
1670                runs.set(runs.get() + 1);
1671                Ok(7)
1672            })
1673            .unwrap();
1674        }
1675        assert_eq!(runs.get(), 2);
1676        assert!(cache.lock().unwrap().is_empty());
1677    }
1678}