Skip to main content

dynoxide/
lib.rs

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