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