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;
63// Where DynamoDB refuses an empty attribute name, and how it words it.
64pub(crate) mod empty_names;
65pub mod errors;
66pub(crate) mod expressions;
67#[cfg(feature = "import")]
68pub mod import;
69#[doc(hidden)]
70pub mod macros;
71#[cfg(feature = "mcp-server")]
72pub mod mcp;
73#[cfg(any(feature = "http-server", feature = "mcp-server"))]
74pub(crate) mod net;
75pub(crate) mod partiql;
76pub mod schema;
77// The order DynamoDB returns set members in, per operation.
78pub(crate) mod set_order;
79// Surface-neutral mapping of serde failures to DynamoDB errors; the HTTP
80// server consumes the request wrappers and the shared message cleaning is
81// used wherever raw serde messages are decoded by hand.
82pub(crate) mod serde_errors;
83// Shared by the HTTP server and the wasm dispatch: both must reject a
84// mistyped field with the same SerializationException, or the two surfaces
85// disagree on requests neither has deserialised yet.
86#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
87pub(crate) mod serialization_checks;
88// Nesting checks for values outside an item or an expression: keys, start
89// keys, legacy conditions and PartiQL parameters.
90pub(crate) mod value_nesting;
91// Bodies nested deeper than the JSON parser follows: still read and answered
92// with DynamoDB's nesting error, on the HTTP server and the wasm engine alike.
93#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
94pub(crate) mod deep_nesting;
95#[cfg(feature = "http-server")]
96pub mod server;
97// Lets a TTL sweep hand the native database lock to waiting requests between
98// pages.
99#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
100pub(crate) mod handoff_mutex;
101
102// One startup-options type for the DynamoDB HTTP server and the MCP HTTP
103// transport, outside both because either builds without the other.
104#[cfg(any(feature = "http-server", feature = "mcp-server"))]
105mod server_options;
106#[cfg(feature = "mcp-server")]
107pub(crate) mod snapshots;
108pub mod storage;
109pub mod storage_backend;
110pub mod streams;
111pub mod ttl;
112pub mod types;
113pub mod validation;
114// The single source of truth for DynamoDB operation names, shared by the HTTP
115// server and the wasm engine API so the two lists cannot drift. Compiled only
116// for the builds that consume it.
117#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
118pub(crate) mod dynamo_ops;
119// Operation-level engine API for the browser playground. The generic dispatch
120// is backend-agnostic and verified natively in tests, so the module compiles
121// for the wasm build and under `cargo test`; a plain native build gains no
122// extra public surface.
123#[cfg(any(feature = "wasm-sqlite", test))]
124pub mod wasm_api;
125#[cfg(feature = "wasm-harness")]
126pub mod wasm_harness;
127
128#[doc(hidden)]
129pub use macros::ItemInsert;
130
131use std::collections::HashMap;
132use std::sync::{Arc, Mutex};
133use web_time::{Duration, Instant};
134
135pub use errors::{DynoxideError, Result};
136pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
137pub use storage_backend::BackendError;
138#[cfg(feature = "wasm-sqlite")]
139pub use storage_backend::WasmBridgeBackend;
140
141pub use types::{AttributeValue, ConversionError, Item};
142
143#[cfg(any(feature = "http-server", feature = "mcp-server"))]
144pub use server_options::ServerOptions;
145
146/// The product version: what `dynoxide --version`, the HTTP headers and the
147/// MCP server report, and what the CLI, containers, browser engine and npm
148/// packages are released under.
149///
150/// Deliberately not the crate version. The crate moves on its own stream for
151/// Rust API changes, which a CLI or container user never consumes. See
152/// `docs/versioning.md`.
153pub const PRODUCT_VERSION: &str = env!("DYNOXIDE_PRODUCT_VERSION");
154
155use actions::vector_lifecycle::VectorIndexLifecycle;
156
157/// Options for `Database::import_items()`.
158#[derive(Debug, Clone, Default)]
159pub struct ImportOptions {
160    /// Whether to record stream events for imported items. Default: false.
161    pub record_streams: bool,
162    /// Whether to set `cached_at` to the current timestamp. Default: false.
163    pub set_cached_at: bool,
164}
165
166/// Result of a bulk import operation.
167#[derive(Debug, Clone)]
168pub struct ImportResult {
169    /// Number of items imported.
170    pub items_imported: usize,
171    /// Total bytes imported (sum of item_size values).
172    pub bytes_imported: usize,
173}
174
175/// One idempotency slot: when the token was claimed, the request hash it was
176/// claimed with, and the response once the call finishes.
177///
178/// `None` in the response position means a call has claimed the token and is
179/// still running. Only the asynchronous driver writes that state; the
180/// synchronous one holds the cache lock across its call instead, so a slot it
181/// finds is always a finished one.
182type TokenSlot<T> = (Instant, u64, Option<T>);
183
184/// Idempotency cache keyed by `ClientRequestToken`.
185type TokenCache<T> = HashMap<String, TokenSlot<T>>;
186
187/// Cached `TransactWriteItems` responses, alongside the image sizes a replay is
188/// billed against. Those sizes are cache bookkeeping, so they ride here rather
189/// than on the response type a caller sees.
190type TransactWriteTokenCache = TokenCache<actions::transact_write_items::CachedWrite>;
191
192/// Cached `ExecuteTransaction` responses. Separate from
193/// [`TransactWriteTokenCache`] because the response type differs and
194/// `ClientRequestToken` idempotency is scoped per API operation in AWS: a token
195/// reused across `TransactWriteItems` and `ExecuteTransaction` executes once in
196/// each, so the two caches are independent by design.
197type ExecuteTransactionTokenCache = TokenCache<actions::execute_transaction::CachedTransaction>;
198
199/// The transactional idempotency caches one engine instance owns.
200///
201/// Opaque by design: the slot shape is an implementation detail. Construct one
202/// with [`TokenCaches::new`] and lend it to a dispatch that needs it.
203#[derive(Default)]
204pub struct TokenCaches {
205    // Read by the native transactional path; a backend-neutral build has no
206    // such caller yet, so scope the exemption to exactly that configuration
207    // rather than blanket-allowing it.
208    #[cfg_attr(
209        not(any(feature = "native-sqlite", feature = "_has-encryption")),
210        allow(dead_code)
211    )]
212    transact_write: Mutex<TransactWriteTokenCache>,
213    execute_transaction: Mutex<ExecuteTransactionTokenCache>,
214}
215
216impl TokenCaches {
217    /// An empty set of caches.
218    pub fn new() -> Self {
219        Self::default()
220    }
221
222    #[cfg(any(feature = "wasm-sqlite", test))]
223    pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
224        &self.execute_transaction
225    }
226
227    /// Lock both caches, for a restore that replaces the data their results
228    /// were computed against.
229    ///
230    /// A restore takes these before the storage lock, keeping the
231    /// cache-then-storage order [`run_idempotent`] relies on, and holds them
232    /// until the restore is done so no transaction can cache a result against
233    /// the data being replaced. A poisoned cache is taken all the same, and
234    /// emptied if the restore succeeds; its poison is left as it was.
235    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
236    pub(crate) fn lock_all(&self) -> TokenCacheGuards<'_> {
237        TokenCacheGuards {
238            transact_write: self
239                .transact_write
240                .lock()
241                .unwrap_or_else(|e| e.into_inner()),
242            execute_transaction: self
243                .execute_transaction
244                .lock()
245                .unwrap_or_else(|e| e.into_inner()),
246        }
247    }
248}
249
250/// Both token caches, held for the length of a restore.
251#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
252pub(crate) struct TokenCacheGuards<'a> {
253    transact_write: std::sync::MutexGuard<'a, TransactWriteTokenCache>,
254    execute_transaction: std::sync::MutexGuard<'a, ExecuteTransactionTokenCache>,
255}
256
257#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
258impl TokenCacheGuards<'_> {
259    /// Forget every cached transaction.
260    pub(crate) fn clear(&mut self) {
261        self.transact_write.clear();
262        self.execute_transaction.clear();
263    }
264}
265
266/// AWS caps `ClientRequestToken` at 36 characters.
267const MAX_TOKEN_LEN: usize = 36;
268
269/// AWS scopes transactional idempotency to a 10-minute window. Entries older
270/// than this are evicted on the next token-bearing call.
271const TOKEN_EXPIRY_SECS: u64 = 600;
272
273/// Reject a token longer than DynamoDB accepts, with its exact message.
274fn validate_token(token: Option<&str>) -> Result<()> {
275    match token_constraint_error(token) {
276        Some(msg) => Err(DynoxideError::ValidationException(
277            validation::envelope_message(&msg),
278        )),
279        None => Ok(()),
280    }
281}
282
283/// The constraint error for a token longer than DynamoDB accepts, without the
284/// envelope, so a caller can report it alongside the request's other member
285/// errors.
286pub(crate) fn token_constraint_error(token: Option<&str>) -> Option<String> {
287    match token {
288        Some(token) if token.len() > MAX_TOKEN_LEN => Some(format!(
289            "Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
290        )),
291        _ => None,
292    }
293}
294
295/// Hash the idempotency key material.
296///
297/// The input is the items or statements only, never `ReturnConsumedCapacity`,
298/// so a same-token call differing only in the capacity mode replays rather than
299/// mismatching. Hashed through [`canonical_json`], so the digest does not depend
300/// on map iteration order.
301fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
302    use std::hash::{Hash, Hasher};
303    let mut hasher = std::collections::hash_map::DefaultHasher::new();
304    canonical_json(input).hash(&mut hasher);
305    hasher.finish()
306}
307
308/// `value` serialised to JSON with every object's keys in sorted order, at any
309/// depth, for hashing or comparing by content.
310///
311/// A `serde_json::Value` keeps an object's keys sorted only while serde_json's
312/// `preserve_order` feature is off. A crate that depends on Dynoxide can turn
313/// that feature on for the whole build, and then a `HashMap`'s keys come out in
314/// its iteration order, which differs between two maps holding the same
315/// entries. So the keys are sorted here rather than left to serde_json.
316pub(crate) fn canonical_json<T: serde::Serialize + ?Sized>(value: &T) -> Vec<u8> {
317    serde_json::to_value(value)
318        .map(sorted_json)
319        .and_then(|v| serde_json::to_vec(&v))
320        .unwrap_or_default()
321}
322
323/// A JSON value with every object's keys sorted, at any depth. Building the
324/// object from sorted entries gives sorted keys whether serde_json keeps
325/// objects in key order or in insertion order.
326pub(crate) fn sorted_json(value: serde_json::Value) -> serde_json::Value {
327    match value {
328        serde_json::Value::Object(map) => {
329            let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
330            entries.sort_by(|a, b| a.0.cmp(&b.0));
331            serde_json::Value::Object(
332                entries
333                    .into_iter()
334                    .map(|(k, v)| (k, sorted_json(v)))
335                    .collect(),
336            )
337        }
338        serde_json::Value::Array(items) => {
339            serde_json::Value::Array(items.into_iter().map(sorted_json).collect())
340        }
341        other => other,
342    }
343}
344
345fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
346    cache
347        .lock()
348        .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
349}
350
351/// Drop entries older than `window`.
352///
353/// The window is a parameter so a test can drive expiry without back-dating an
354/// `Instant`, which is not always possible: `Instant`'s origin is boot, so
355/// subtracting ten minutes fails on a machine that has been up for less.
356fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
357    cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
358}
359
360/// The expiry window every caller outside the tests uses.
361fn token_window() -> Duration {
362    Duration::from_secs(TOKEN_EXPIRY_SECS)
363}
364
365/// Run a transactional operation with `ClientRequestToken` idempotency, shared
366/// by [`Database::transact_write_items`] and [`Database::execute_transaction`].
367///
368/// The cache lock is held across the whole first call (check, execute, insert)
369/// so two concurrent same-token calls cannot both execute: the second
370/// serialises behind the first and replays. That hold is the exclusion, which
371/// is why this driver never needs to claim a slot the way the asynchronous one
372/// does. A cache hit clones the stored response, releases the lock, then
373/// rebuilds the reply via `replay` (which re-derives read capacity).
374///
375/// Lock ordering is cache-then-storage: `execute` takes the storage lock
376/// second, and no native path takes storage first and then a token cache, so
377/// there is no reverse path to deadlock against. A restore takes both caches
378/// (transact-write, then execute-transaction) before storage. The wasm
379/// dispatch takes a cache while it holds its backend, but only briefly and
380/// never across an await, and wasm has no restore. Any future code touching
381/// both locks must keep this order.
382///
383/// A failed `execute` is propagated without caching, so a same-token retry
384/// re-executes.
385#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
386fn run_idempotent<T, H, E, R>(
387    cache: &Mutex<TokenCache<T>>,
388    token: Option<&str>,
389    hash_input: &H,
390    execute: E,
391    replay: R,
392) -> Result<T>
393where
394    T: Clone,
395    H: serde::Serialize,
396    E: FnOnce() -> Result<T>,
397    R: FnOnce(&T) -> T,
398{
399    validate_token(token)?;
400
401    // No idempotency token: execute without touching the cache.
402    let Some(token) = token else {
403        return execute();
404    };
405    let hash = request_hash(hash_input);
406
407    let mut cache = lock_cache(cache)?;
408    evict_expired(&mut cache, token_window());
409    let cached = match cache.get(token) {
410        Some((_, cached_hash, _)) if *cached_hash != hash => {
411            return Err(DynoxideError::IdempotentParameterMismatchException(
412                "An error occurred (IdempotentParameterMismatchException)".to_string(),
413            ));
414        }
415        // A claimed-but-unfinished slot is treated as absent: this driver
416        // holds its lock across the call, so it never writes one and never
417        // shares a cache with the driver that does.
418        Some((_, _, slot)) => slot.clone(),
419        None => None,
420    };
421    if let Some(cached) = cached {
422        // Clone the cached response, release the lock, then rebuild the reply.
423        drop(cache);
424        return Ok(replay(&cached));
425    }
426    // Cache miss: execute and record the result while still holding the lock,
427    // so a concurrent same-token call waits and then replays rather than
428    // executing the transaction a second time.
429    let resp = execute()?;
430    cache.insert(
431        token.to_string(),
432        (Instant::now(), hash, Some(resp.clone())),
433    );
434    Ok(resp)
435}
436
437/// What a refused caller is told. Unreachable from any shipped surface today,
438/// so it stands for "something upstream stopped serialising callers".
439#[cfg(any(feature = "wasm-sqlite", test))]
440const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
441
442/// What a caller found when it tried to claim a token slot.
443#[cfg(any(feature = "wasm-sqlite", test))]
444enum TokenClaim<T> {
445    /// The slot was free and now belongs to this call, which must either
446    /// complete it or clear it. Carries the stamp that identifies the claim,
447    /// so a settle can tell its own slot from one a later call has taken over.
448    Marked(Instant),
449    /// Another call holds the slot and has not finished.
450    InFlight,
451    /// A call under this token used different request material.
452    Mismatch,
453    /// A finished call under this token; replay its response.
454    Hit(T),
455}
456
457/// Look up a token and, when it is free, claim it - under one lock.
458///
459/// The lookup and the claim cannot be separate acquisitions: two callers would
460/// both see a free slot and both proceed, which is the race the claim exists to
461/// prevent.
462#[cfg(any(feature = "wasm-sqlite", test))]
463fn lookup_or_claim<T: Clone>(
464    cache: &Mutex<TokenCache<T>>,
465    token: &str,
466    hash: u64,
467) -> Result<TokenClaim<T>> {
468    lookup_or_claim_within(cache, token, hash, token_window())
469}
470
471/// [`lookup_or_claim`] with an explicit expiry window, so a test can drive
472/// expiry without depending on how long the machine has been running.
473#[cfg(any(feature = "wasm-sqlite", test))]
474fn lookup_or_claim_within<T: Clone>(
475    cache: &Mutex<TokenCache<T>>,
476    token: &str,
477    hash: u64,
478    window: Duration,
479) -> Result<TokenClaim<T>> {
480    let mut cache = lock_cache(cache)?;
481    evict_expired(&mut cache, window);
482    Ok(match cache.get(token) {
483        Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
484        Some((_, _, None)) => TokenClaim::InFlight,
485        Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
486        None => {
487            let claimed_at = Instant::now();
488            cache.insert(token.to_string(), (claimed_at, hash, None));
489            TokenClaim::Marked(claimed_at)
490        }
491    })
492}
493
494/// Is this slot still the claim `claimed_at` made?
495///
496/// A claim can expire while its call is in flight, and a later call can then
497/// take the token over. The original call must not settle or release a slot
498/// that is no longer its own, or it would overwrite the newcomer's response or
499/// free a claim still being worked on.
500#[cfg(any(feature = "wasm-sqlite", test))]
501fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
502    matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
503}
504
505/// Settle a claimed slot with the response its call produced.
506#[cfg(any(feature = "wasm-sqlite", test))]
507fn record_complete<T>(
508    cache: &Mutex<TokenCache<T>>,
509    token: &str,
510    claimed_at: Instant,
511    hash: u64,
512    resp: T,
513) -> Result<()> {
514    let mut cache = lock_cache(cache)?;
515    if still_ours(&cache, token, claimed_at) {
516        cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
517    }
518    Ok(())
519}
520
521/// Release a claimed slot whose call failed, so a retry re-executes rather than
522/// replaying the failure.
523#[cfg(any(feature = "wasm-sqlite", test))]
524fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
525    let mut cache = lock_cache(cache)?;
526    if still_ours(&cache, token, claimed_at) {
527        cache.remove(token);
528    }
529    Ok(())
530}
531
532/// `run_idempotent` for a caller that cannot block.
533///
534/// Takes a future rather than a closure, because the wasm engine awaits real
535/// bridge promises. That rules out holding the cache lock across the call, so
536/// exclusion comes from claiming the token slot up front instead: a concurrent
537/// same-token caller finds the claim and is refused rather than starting a
538/// second execution.
539///
540/// A refused caller currently gets an internal error. No shipped surface can
541/// reach it - the engine serialises callers on the backend lock before either
542/// arrives here - so it stands for "something upstream stopped serialising"
543/// until an operation exists that can genuinely produce it.
544#[cfg(any(feature = "wasm-sqlite", test))]
545async fn run_idempotent_async<T, H, F, R>(
546    cache: &Mutex<TokenCache<T>>,
547    token: Option<&str>,
548    hash_input: &H,
549    execute: F,
550    replay: R,
551) -> Result<T>
552where
553    T: Clone,
554    H: serde::Serialize,
555    F: std::future::Future<Output = Result<T>>,
556    R: FnOnce(&T) -> T,
557{
558    validate_token(token)?;
559
560    let Some(token) = token else {
561        return execute.await;
562    };
563    let hash = request_hash(hash_input);
564
565    let claimed_at = match lookup_or_claim(cache, token, hash)? {
566        TokenClaim::Hit(cached) => return Ok(replay(&cached)),
567        TokenClaim::Mismatch => {
568            return Err(DynoxideError::IdempotentParameterMismatchException(
569                "An error occurred (IdempotentParameterMismatchException)".to_string(),
570            ));
571        }
572        TokenClaim::InFlight => {
573            return Err(DynoxideError::InternalServerError(
574                IN_FLIGHT_MESSAGE.to_string(),
575            ));
576        }
577        TokenClaim::Marked(claimed_at) => claimed_at,
578    };
579
580    match execute.await {
581        Ok(resp) => {
582            // The work has committed by this point, so a bookkeeping failure
583            // must not be reported as a failed call. The worst it costs is a
584            // replay: the slot stays claimed until it expires.
585            let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
586            Ok(resp)
587        }
588        Err(e) => {
589            let _ = clear_claim(cache, token, claimed_at);
590            Err(e)
591        }
592    }
593}
594
595/// The native storage backend: the rusqlite-backed [`storage::Storage`].
596///
597/// `Database`'s type parameter defaults to this, so existing native callers
598/// keep writing `Database` and get the synchronous rusqlite-backed engine.
599#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
600pub type RusqliteBackend = storage::Storage;
601
602/// The native, synchronous `Database`.
603///
604/// Alias for the default [`Database`] monomorphisation over
605/// [`RusqliteBackend`]. It exposes the historical synchronous public API
606/// unchanged: each method drives an async handler future to completion with
607/// `block_on`. Because the native backend's futures never suspend, that
608/// `block_on` never parks the thread.
609#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
610pub type NativeDatabase = Database<RusqliteBackend>;
611
612/// The wasm, asynchronous `Database` over the wasm SQLite backend.
613///
614/// Alias for [`Database`] monomorphised over [`WasmBridgeBackend`]. Unlike
615/// [`NativeDatabase`], its methods are `async fn` and never call `block_on`:
616/// the wasm backend awaits real SQLite-bridge promises, and the wasm main thread
617/// must not block.
618#[cfg(feature = "wasm-sqlite")]
619pub type WasmDatabase = Database<WasmBridgeBackend>;
620
621/// Build-visible discriminator for the wasm-sqlite backend.
622///
623/// `true` when built with `--no-default-features --features wasm-sqlite`,
624/// `false` otherwise. The wasm backend covers CRUD, query, scan, GSI/LSI and
625/// PartiQL and passes the conformance cases for all of them, but it leaves
626/// several operations unimplemented (`TransactWriteItems`, streams, tags, TTL).
627/// Read this constant to tell which build you hold; see `docs/versioning.md`
628/// for what each one covers.
629///
630/// This names the build path rather than a maturity level, so it stays correct
631/// as the wasm backend matures.
632#[cfg(feature = "wasm-sqlite")]
633pub const WASM_BACKEND: bool = true;
634/// Build-visible discriminator for the wasm-sqlite backend. See the
635/// `wasm-sqlite` variant for details.
636#[cfg(not(feature = "wasm-sqlite"))]
637pub const WASM_BACKEND: bool = false;
638
639/// The main entry point for the DynamoDB emulator.
640///
641/// Generic over the storage backend `S`, monomorphised (no `dyn`). The type
642/// parameter defaults to [`RusqliteBackend`], so `Database` means the native
643/// engine and the public synchronous API is preserved via [`NativeDatabase`].
644///
645/// Wraps a storage layer and provides DynamoDB-compatible operations.
646/// Thread-safe: backend access is serialised by one mutex shared through an
647/// `Arc`, which a TTL sweep steps aside from between pages to let waiting
648/// requests in. Clone freely across threads.
649#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
650pub struct Database<S = RusqliteBackend> {
651    inner: Arc<handoff_mutex::HandoffMutex<S>>,
652    tokens: Arc<TokenCaches>,
653    vector_lifecycle: Arc<VectorIndexLifecycle>,
654}
655
656/// Serialises backend access on the backend-neutral build. On wasm this is an
657/// async mutex: the bridge calls genuinely suspend, so a std mutex held across
658/// them would deadlock concurrent callers on the single-threaded runtime,
659/// whereas an async mutex queues them. Off wasm (the degenerate no-backend
660/// shell, which can never construct a `Database`) a std mutex stands in.
661#[cfg(all(
662    not(any(feature = "native-sqlite", feature = "_has-encryption")),
663    feature = "wasm-sqlite"
664))]
665use async_lock::Mutex as BackendMutex;
666#[cfg(all(
667    not(any(feature = "native-sqlite", feature = "_has-encryption")),
668    not(feature = "wasm-sqlite")
669))]
670use std::sync::Mutex as BackendMutex;
671
672/// The main entry point for the DynamoDB emulator (backend-neutral build).
673///
674/// On a build with no native backend (for example the `wasm-sqlite` build)
675/// there is no native default, so the backend must be named explicitly - for
676/// example `Database<WasmBridgeBackend>`, aliased as `WasmDatabase`.
677///
678/// Wraps a storage layer and provides DynamoDB-compatible operations. Backend
679/// access is serialised by [`BackendMutex`] (an async mutex on wasm); clone
680/// freely, only the `Arc`s are copied.
681#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
682pub struct Database<S> {
683    inner: Arc<BackendMutex<S>>,
684    tokens: Arc<TokenCaches>,
685    vector_lifecycle: Arc<VectorIndexLifecycle>,
686}
687
688// Hand-written so cloning never requires `S: Clone`; only the `Arc`s clone.
689impl<S> Clone for Database<S> {
690    fn clone(&self) -> Self {
691        Self {
692            inner: Arc::clone(&self.inner),
693            tokens: Arc::clone(&self.tokens),
694            vector_lifecycle: Arc::clone(&self.vector_lifecycle),
695        }
696    }
697}
698
699#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
700impl Database<RusqliteBackend> {
701    /// Open a persistent database at the given path.
702    pub fn new(path: &str) -> Result<Self> {
703        let storage = storage::Storage::new(path)?;
704        Ok(Self {
705            inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
706            tokens: Arc::new(TokenCaches::new()),
707            vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
708        })
709    }
710
711    /// Open or create an encrypted database at the given path.
712    ///
713    /// The key must be a 64-character hex string representing a 32-byte key.
714    /// Example: `"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"`
715    ///
716    /// The key is passed to SQLCipher via `PRAGMA key`. The database file is
717    /// encrypted at rest using AES-256-CBC.
718    ///
719    /// # Security
720    ///
721    /// This function borrows the key as `&str` and cannot zeroize the caller's
722    /// copy. The caller is responsible for zeroizing owned key material after
723    /// this call returns (e.g., by using `zeroize::Zeroizing<String>`).
724    ///
725    /// # Errors
726    ///
727    /// Returns an error if:
728    /// - The key format is invalid (not 64 hex characters)
729    /// - The database exists but was created without encryption
730    /// - The database exists but the key is wrong
731    #[cfg(feature = "_has-encryption")]
732    pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
733        if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
734            return Err(DynoxideError::ValidationException(
735                "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
736            ));
737        }
738
739        let storage = storage::Storage::new_encrypted(path, key)?;
740        Ok(Self {
741            inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
742            tokens: Arc::new(TokenCaches::new()),
743            vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
744        })
745    }
746
747    /// Open an in-memory database (for tests and ephemeral use).
748    pub fn memory() -> Result<Self> {
749        let storage = storage::Storage::memory()?;
750        Ok(Self {
751            inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
752            tokens: Arc::new(TokenCaches::new()),
753            vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
754        })
755    }
756
757    /// Open an in-memory database reading time from `clock`.
758    ///
759    /// The counterpart to [`storage::Storage::with_clock`] for a caller that
760    /// only has a `Database`: an integration test outside `src/` can hand in a
761    /// [`ManualClock`](storage_backend::ManualClock) and drive the vector index
762    /// creation lifecycle without waiting on real time.
763    pub fn memory_with_clock(clock: Arc<dyn storage_backend::Clock>) -> Result<Self> {
764        let storage = storage::Storage::memory()?.with_clock(clock);
765        Ok(Self {
766            inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
767            tokens: Arc::new(TokenCaches::new()),
768            vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
769        })
770    }
771
772    /// Execute a closure with exclusive access to the storage layer.
773    pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
774    where
775        F: FnOnce(&storage::Storage) -> Result<T>,
776    {
777        let guard = self
778            .inner
779            .lock()
780            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
781        f(&guard)
782    }
783
784    /// Execute a closure with mutable exclusive access to the storage layer.
785    pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
786    where
787        F: FnOnce(&mut storage::Storage) -> Result<T>,
788    {
789        let mut guard = self
790            .inner
791            .lock()
792            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
793        f(&mut guard)
794    }
795
796    // -------------------------------------------------------------------
797    // Table operations
798    // -------------------------------------------------------------------
799
800    /// Create a new DynamoDB table.
801    pub fn create_table(
802        &self,
803        request: actions::create_table::CreateTableRequest,
804    ) -> Result<actions::create_table::CreateTableResponse> {
805        self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
806    }
807
808    /// Delete a DynamoDB table.
809    pub fn delete_table(
810        &self,
811        request: actions::delete_table::DeleteTableRequest,
812    ) -> Result<actions::delete_table::DeleteTableResponse> {
813        self.with_storage(|s| {
814            pollster::block_on(actions::delete_table::execute(
815                s,
816                request,
817                &self.vector_lifecycle,
818            ))
819        })
820    }
821
822    /// Describe a DynamoDB table.
823    pub fn describe_table(
824        &self,
825        request: actions::describe_table::DescribeTableRequest,
826    ) -> Result<actions::describe_table::DescribeTableResponse> {
827        self.with_storage(|s| {
828            pollster::block_on(actions::describe_table::execute(
829                s,
830                request,
831                &self.vector_lifecycle,
832            ))
833        })
834    }
835
836    /// Update a DynamoDB table (add/remove GSIs).
837    pub fn update_table(
838        &self,
839        request: actions::update_table::UpdateTableRequest,
840    ) -> Result<actions::update_table::UpdateTableResponse> {
841        self.with_storage(|s| {
842            pollster::block_on(actions::update_table::execute(
843                s,
844                request,
845                &self.vector_lifecycle,
846            ))
847        })
848    }
849
850    /// List DynamoDB tables.
851    pub fn list_tables(
852        &self,
853        request: actions::list_tables::ListTablesRequest,
854    ) -> Result<actions::list_tables::ListTablesResponse> {
855        self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
856    }
857
858    // -------------------------------------------------------------------
859    // Tags
860    // -------------------------------------------------------------------
861
862    /// Add tags to a DynamoDB table.
863    pub fn tag_resource(
864        &self,
865        request: actions::tag_resource::TagResourceRequest,
866    ) -> Result<actions::tag_resource::TagResourceResponse> {
867        self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
868    }
869
870    /// Remove tags from a DynamoDB table.
871    pub fn untag_resource(
872        &self,
873        request: actions::untag_resource::UntagResourceRequest,
874    ) -> Result<actions::untag_resource::UntagResourceResponse> {
875        self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
876    }
877
878    /// List tags for a DynamoDB table.
879    pub fn list_tags_of_resource(
880        &self,
881        request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
882    ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
883        self.with_storage(|s| {
884            pollster::block_on(actions::list_tags_of_resource::execute(s, request))
885        })
886    }
887
888    // -------------------------------------------------------------------
889    // Item operations
890    // -------------------------------------------------------------------
891
892    /// Put an item into a DynamoDB table.
893    pub fn put_item(
894        &self,
895        request: actions::put_item::PutItemRequest,
896    ) -> Result<actions::put_item::PutItemResponse> {
897        self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
898    }
899
900    /// Get an item from a DynamoDB table.
901    pub fn get_item(
902        &self,
903        request: actions::get_item::GetItemRequest,
904    ) -> Result<actions::get_item::GetItemResponse> {
905        self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
906    }
907
908    /// Delete an item from a DynamoDB table.
909    pub fn delete_item(
910        &self,
911        request: actions::delete_item::DeleteItemRequest,
912    ) -> Result<actions::delete_item::DeleteItemResponse> {
913        self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
914    }
915
916    /// Update an item in a DynamoDB table.
917    pub fn update_item(
918        &self,
919        request: actions::update_item::UpdateItemRequest,
920    ) -> Result<actions::update_item::UpdateItemResponse> {
921        self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
922    }
923
924    // -------------------------------------------------------------------
925    // Batch operations
926    // -------------------------------------------------------------------
927
928    /// Batch get items from one or more DynamoDB tables.
929    pub fn batch_get_item(
930        &self,
931        request: actions::batch_get_item::BatchGetItemRequest,
932    ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
933        self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
934    }
935
936    /// Batch write items to one or more DynamoDB tables.
937    pub fn batch_write_item(
938        &self,
939        request: actions::batch_write_item::BatchWriteItemRequest,
940    ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
941        self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
942    }
943
944    /// Import items in bulk, bypassing per-item size validation.
945    ///
946    /// All items are inserted in a single transaction. If any item fails,
947    /// the entire import is rolled back. Items with duplicate keys within
948    /// the batch are resolved by last-write-wins (later items in the vec
949    /// overwrite earlier items with the same primary key).
950    ///
951    /// GSI entries are maintained: items with GSI key attributes are
952    /// inserted into the appropriate GSI tables. Items missing GSI key
953    /// attributes are silently omitted from the GSI (sparse GSI behavior,
954    /// matching DynamoDB semantics).
955    ///
956    /// Stream records are NOT generated by default. Use
957    /// `ImportOptions { record_streams: true, .. }` if stream recording is needed.
958    pub fn import_items(
959        &self,
960        table_name: &str,
961        items: Vec<Item>,
962        options: ImportOptions,
963    ) -> Result<ImportResult> {
964        self.with_storage(|s| {
965            pollster::block_on(actions::import_items::execute(
966                s, table_name, items, &options,
967            ))
968        })
969    }
970
971    /// Import items in bulk, skipping GSI DELETE-before-INSERT.
972    ///
973    /// Same as `import_items` but assumes the database is fresh (no
974    /// pre-existing rows), so GSI cleanup deletes are skipped entirely.
975    /// This eliminates the dominant bottleneck for large imports.
976    #[cfg(feature = "import")]
977    pub(crate) fn import_items_fresh(
978        &self,
979        table_name: &str,
980        items: Vec<Item>,
981        options: ImportOptions,
982    ) -> Result<ImportResult> {
983        self.with_storage(|s| {
984            pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
985                s, table_name, items, &options,
986            ))
987        })
988    }
989
990    // -------------------------------------------------------------------
991    // Bulk loading
992    // -------------------------------------------------------------------
993
994    /// Set aggressive SQLite PRAGMAs for bulk loading.
995    ///
996    /// Only safe when data loss on crash is acceptable (e.g., fresh import).
997    /// Call `disable_bulk_loading()` after the import to restore normal settings.
998    pub fn enable_bulk_loading(&self) -> Result<()> {
999        self.with_storage(|s| s.enable_bulk_loading())
1000    }
1001
1002    /// Restore normal SQLite PRAGMAs after bulk loading.
1003    pub fn disable_bulk_loading(&self) -> Result<()> {
1004        self.with_storage(|s| s.disable_bulk_loading())
1005    }
1006
1007    // -------------------------------------------------------------------
1008    // Query & Scan
1009    // -------------------------------------------------------------------
1010
1011    /// Query a DynamoDB table.
1012    pub fn query(
1013        &self,
1014        request: actions::query::QueryRequest,
1015    ) -> Result<actions::query::QueryResponse> {
1016        self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
1017    }
1018
1019    /// Scan a DynamoDB table.
1020    pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
1021        self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
1022    }
1023
1024    /// Search a vector index with exact brute-force KNN, ranked best first.
1025    pub fn search_vectors(
1026        &self,
1027        request: actions::search_vectors::SearchVectorsRequest,
1028    ) -> Result<actions::search_vectors::SearchVectorsResponse> {
1029        self.with_storage(|s| {
1030            pollster::block_on(actions::search_vectors::execute(
1031                s,
1032                request,
1033                &self.vector_lifecycle,
1034            ))
1035        })
1036    }
1037
1038    // -------------------------------------------------------------------
1039    // Transactions
1040    // -------------------------------------------------------------------
1041
1042    /// Execute a transactional write (up to 100 actions, all-or-nothing).
1043    ///
1044    /// Honours `ClientRequestToken` idempotency via `run_idempotent`: a
1045    /// same-token, same-items call within the expiry window replays the stored
1046    /// result (reported as transactional read capacity) without re-applying the
1047    /// writes.
1048    pub fn transact_write_items(
1049        &self,
1050        request: actions::transact_write_items::TransactWriteItemsRequest,
1051    ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
1052        // Ahead of the cache, not only inside the work: a replayed token skips
1053        // the work entirely, and the token is keyed on the items alone, so a
1054        // replay may carry a capacity mode the first call never did.
1055        actions::transact_write_items::check_request(&request)?;
1056        run_idempotent(
1057            &self.tokens.transact_write,
1058            request.client_request_token.as_deref(),
1059            &request.transact_items,
1060            || {
1061                self.with_storage(|s| {
1062                    pollster::block_on(actions::transact_write_items::execute_cached(
1063                        s,
1064                        request.clone(),
1065                    ))
1066                })
1067            },
1068            |cached| {
1069                // The replay recomputes a transactional read cost against the
1070                // image sizes the first call recorded (4KB read granularity,
1071                // diverging from that call's 1KB-granular write above 1KB) and
1072                // carries over its item collection metrics.
1073                actions::transact_write_items::replay_response(
1074                    cached,
1075                    &request.return_consumed_capacity,
1076                )
1077            },
1078        )
1079        .map(|cached| cached.response)
1080    }
1081
1082    /// Execute a transactional read (up to 100 gets).
1083    pub fn transact_get_items(
1084        &self,
1085        request: actions::transact_get_items::TransactGetItemsRequest,
1086    ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
1087        self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
1088    }
1089
1090    // -------------------------------------------------------------------
1091    // Streams
1092    // -------------------------------------------------------------------
1093
1094    /// List DynamoDB Streams.
1095    pub fn list_streams(
1096        &self,
1097        request: actions::list_streams::ListStreamsRequest,
1098    ) -> Result<actions::list_streams::ListStreamsResponse> {
1099        self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
1100    }
1101
1102    /// Describe a DynamoDB Stream.
1103    pub fn describe_stream(
1104        &self,
1105        request: actions::describe_stream::DescribeStreamRequest,
1106    ) -> Result<actions::describe_stream::DescribeStreamResponse> {
1107        self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
1108    }
1109
1110    /// Get a shard iterator.
1111    pub fn get_shard_iterator(
1112        &self,
1113        request: actions::get_shard_iterator::GetShardIteratorRequest,
1114    ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
1115        self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
1116    }
1117
1118    /// Get stream records.
1119    pub fn get_records(
1120        &self,
1121        request: actions::get_records::GetRecordsRequest,
1122    ) -> Result<actions::get_records::GetRecordsResponse> {
1123        self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
1124    }
1125
1126    // -------------------------------------------------------------------
1127    // TTL
1128    // -------------------------------------------------------------------
1129
1130    /// Update time to live configuration.
1131    pub fn update_time_to_live(
1132        &self,
1133        request: actions::update_time_to_live::UpdateTimeToLiveRequest,
1134    ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
1135        self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
1136    }
1137
1138    /// Describe time to live configuration.
1139    pub fn describe_time_to_live(
1140        &self,
1141        request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
1142    ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
1143        self.with_storage(|s| {
1144            pollster::block_on(actions::describe_time_to_live::execute(s, request))
1145        })
1146    }
1147
1148    /// Run a TTL sweep, deleting expired items from all TTL-enabled tables.
1149    /// Returns the number of items deleted.
1150    ///
1151    /// The sweep takes the database lock a page at a time and lets any request
1152    /// already waiting for it go first between pages, so other requests on this
1153    /// `Database` wait for one page of the sweep rather than all of it. The
1154    /// first page of each table also counts the table's rows, so the longest
1155    /// single hold is that count plus one page. Between pages the sweep waits
1156    /// up to a second for the queued requests to have their turn, including
1157    /// the time they hold the lock, and then goes ahead.
1158    ///
1159    /// A sweep reads no more of a table than it held when the sweep reached
1160    /// it, so an item written while the sweep is under way may be left to the
1161    /// next one.
1162    pub fn sweep_ttl(&self) -> Result<usize> {
1163        self.sweep_ttl_by_page(|_| {}, |_| {})
1164    }
1165
1166    /// [`sweep_ttl`](Self::sweep_ttl), with a hook run at the end of each page
1167    /// while the lock is still held, and one run between pages once the lock
1168    /// has been released and handed on. Both are given the page number,
1169    /// counting from 1.
1170    fn sweep_ttl_by_page(
1171        &self,
1172        mut in_page: impl FnMut(usize),
1173        mut between_pages: impl FnMut(usize),
1174    ) -> Result<usize> {
1175        let mut sweep = self.with_storage(|s| pollster::block_on(ttl::Sweep::begin(s)))?;
1176        let mut page = 0;
1177        loop {
1178            page += 1;
1179            // The hand-off starts while this page still holds the lock, so it
1180            // is owed to exactly the requests queued behind the page.
1181            let hand_off = self.with_storage(|s| {
1182                let more = pollster::block_on(sweep.next_page(s))?;
1183                in_page(page);
1184                Ok(more.then(|| self.inner.start_hand_off()))
1185            })?;
1186            let Some(hand_off) = hand_off else {
1187                return Ok(sweep.deleted());
1188            };
1189            hand_off.wait();
1190            between_pages(page);
1191        }
1192    }
1193
1194    // -------------------------------------------------------------------
1195    // PartiQL
1196    // -------------------------------------------------------------------
1197
1198    /// Execute a single PartiQL statement.
1199    pub fn execute_statement(
1200        &self,
1201        request: actions::execute_statement::ExecuteStatementRequest,
1202    ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
1203        self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
1204    }
1205
1206    /// Execute PartiQL statements transactionally (all-or-nothing).
1207    ///
1208    /// Honours `ClientRequestToken` idempotency via `run_idempotent`, the same
1209    /// way as [`transact_write_items`](Self::transact_write_items): a same-token,
1210    /// same-statements call within the expiry window replays the stored result
1211    /// without re-applying the statements. The cache is separate from the
1212    /// `TransactWriteItems` one (see `ExecuteTransactionTokenCache`).
1213    pub fn execute_transaction(
1214        &self,
1215        request: actions::execute_transaction::ExecuteTransactionRequest,
1216    ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
1217        // Ahead of the cache, not only inside the work: a replayed token skips
1218        // the work entirely, and the token is keyed on the statements alone, so
1219        // a replay may carry a capacity mode or token the first call never did.
1220        // The token, statements and mode are refused together, listed.
1221        actions::execute_transaction::check_members(&request)?;
1222        run_idempotent(
1223            &self.tokens.execute_transaction,
1224            request.client_request_token.as_deref(),
1225            &actions::execute_transaction::token_statements(&request.transact_statements),
1226            || {
1227                self.with_storage(|s| {
1228                    pollster::block_on(actions::execute_transaction::execute_cached(
1229                        s,
1230                        request.clone(),
1231                    ))
1232                })
1233            },
1234            |cached| {
1235                // The replay reports transactional read capacity against the
1236                // sizes the first call recorded, and carries over its responses.
1237                actions::execute_transaction::replay_response(
1238                    cached,
1239                    &request.return_consumed_capacity,
1240                )
1241            },
1242        )
1243        .map(|cached| cached.response)
1244    }
1245
1246    /// Execute a batch of PartiQL statements.
1247    pub fn batch_execute_statement(
1248        &self,
1249        request: actions::batch_execute_statement::BatchExecuteStatementRequest,
1250    ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
1251        self.with_storage(|s| {
1252            pollster::block_on(actions::batch_execute_statement::execute(s, request))
1253        })
1254    }
1255
1256    // -------------------------------------------------------------------
1257    // Cache tracking
1258    // -------------------------------------------------------------------
1259
1260    /// Update the `cached_at` timestamp for a single item.
1261    ///
1262    /// Used by cache layers to track when items were last fetched from a
1263    /// remote source. The timestamp is a Unix epoch in seconds (f64).
1264    pub fn touch_cached_at(
1265        &self,
1266        table_name: &str,
1267        pk: &str,
1268        sk: &str,
1269        timestamp: f64,
1270    ) -> Result<()> {
1271        self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
1272    }
1273
1274    /// Get items ordered by `cached_at` (oldest first) for LRU eviction.
1275    ///
1276    /// Returns `(pk, sk, item_size)` tuples. Items with NULL `cached_at`
1277    /// are excluded (they were never cached from a remote source).
1278    pub fn get_lru_items(
1279        &self,
1280        table_name: &str,
1281        limit: usize,
1282    ) -> Result<Vec<(String, String, i64)>> {
1283        self.with_storage(|s| s.get_lru_items(table_name, limit))
1284    }
1285
1286    // -------------------------------------------------------------------
1287    // Introspection
1288    // -------------------------------------------------------------------
1289
1290    /// Get the database file path, or `None` for in-memory databases.
1291    pub fn db_path(&self) -> Result<Option<String>> {
1292        self.with_storage(|s| Ok(s.db_path()))
1293    }
1294
1295    /// Get the total database size in bytes.
1296    pub fn db_size_bytes(&self) -> Result<u64> {
1297        self.with_storage(|s| s.db_size_bytes())
1298    }
1299
1300    /// Count the number of DynamoDB tables.
1301    pub fn table_count(&self) -> Result<usize> {
1302        self.with_storage(|s| s.table_count())
1303    }
1304
1305    /// Get per-table statistics: name, item count, and approximate size in bytes.
1306    pub fn table_stats(&self) -> Result<Vec<TableStats>> {
1307        self.with_storage(|s| s.table_stats())
1308    }
1309
1310    /// Get metadata for a specific table (key schema, GSIs, TTL config, etc.).
1311    pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
1312        self.with_storage(|s| s.get_table_metadata(table_name))
1313    }
1314
1315    /// Get combined database info atomically in a single lock acquisition.
1316    ///
1317    /// Returns path, size, table count, and per-table stats + metadata.
1318    /// Avoids the consistency issues of calling individual methods separately.
1319    pub fn database_info(&self) -> Result<DatabaseInfo> {
1320        self.with_storage(|s| s.database_info())
1321    }
1322
1323    // -------------------------------------------------------------------
1324    // Snapshot operations
1325    // -------------------------------------------------------------------
1326
1327    /// Run VACUUM to compact the database file in place.
1328    pub fn vacuum(&self) -> Result<()> {
1329        self.with_storage(|s| s.vacuum())
1330    }
1331
1332    /// Create a snapshot of the database by copying it to the given path.
1333    ///
1334    /// Uses SQLite's `VACUUM INTO` which works for both in-memory and
1335    /// file-backed databases. The snapshot is a standalone SQLite file.
1336    pub fn vacuum_into(&self, path: &str) -> Result<()> {
1337        self.with_storage(|s| s.vacuum_into(path))
1338    }
1339
1340    /// Restore the database from a snapshot file.
1341    ///
1342    /// Uses SQLite's backup API to replace the current database contents
1343    /// with the snapshot. Works for both in-memory and file-backed databases.
1344    /// The snapshot is opened read-only, so a path that doesn't exist is an
1345    /// error, as is a file that isn't a Dynoxide snapshot; it is copied and
1346    /// brought up to date in a private temporary database before anything here
1347    /// changes, so a refused source, a failed copy or a failed migration leaves
1348    /// the current database as it was. A snapshot in WAL mode may be left with
1349    /// its `-wal` and `-shm` files beside it. After a restore, transactions
1350    /// cached for idempotent retries and vector index creation windows are
1351    /// forgotten, as the data they described has been replaced.
1352    pub fn restore_from(&self, path: &str) -> Result<()> {
1353        self.restore_with(|s| s.restore_from(path))
1354    }
1355
1356    /// Run `restore` and forget what this engine remembers about the data it
1357    /// replaced: the idempotency caches and the vector index creation windows.
1358    ///
1359    /// Lock ordering is cache-then-storage, as in [`run_idempotent`]: both
1360    /// token caches are taken before the storage lock, never after it, so a
1361    /// restore can't deadlock against a tokened transaction that holds its
1362    /// cache and is waiting for storage. The vector lifecycle's lock is only
1363    /// ever taken briefly inside the storage lock, as it is here.
1364    fn restore_with(
1365        &self,
1366        restore: impl FnOnce(&mut storage::Storage) -> Result<()>,
1367    ) -> Result<()> {
1368        let mut tokens = self.tokens.lock_all();
1369        self.with_storage_mut(|s| {
1370            restore(s)?;
1371            tokens.clear();
1372            self.vector_lifecycle.forget_all();
1373            Ok(())
1374        })
1375    }
1376
1377    /// Backup the current database to a new in-memory SQLite connection.
1378    ///
1379    /// Returns an owned `Connection` holding a complete copy. Used for
1380    /// in-memory snapshot storage: no filesystem side-effects.
1381    #[cfg(feature = "mcp-server")]
1382    pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
1383        self.with_storage(|s| s.backup_to_memory())
1384    }
1385
1386    /// Restore the database from an in-memory SQLite connection.
1387    ///
1388    /// Replaces current contents with the source connection's data, and
1389    /// forgets the engine state that described the old data, as
1390    /// [`Database::restore_from`] does.
1391    #[cfg(feature = "mcp-server")]
1392    pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
1393        self.restore_with(|s| s.restore_from_connection(source))
1394    }
1395}
1396
1397/// The wasm, asynchronous facade over the wasm SQLite backend.
1398///
1399/// Mirrors the native facade method-for-method, but each call is `async` and
1400/// awaits the shared action handler directly - there is no `block_on`, because
1401/// the wasm backend's bridge calls genuinely suspend.
1402///
1403/// Calls on one instance are serialised: each holds an async mutex over the
1404/// single SQLite connection for the whole handler, so a transaction's
1405/// begin..commit cannot interleave with another call, and concurrent callers
1406/// (for example two awaited operations on one `WasmDatabase`) queue rather
1407/// than deadlock. Because the mutex is async, queuing suspends instead of
1408/// blocking the single-threaded runtime; because there is only ever one
1409/// writer at a time, `BEGIN IMMEDIATE` cannot return `SQLITE_BUSY`.
1410#[cfg(feature = "wasm-sqlite")]
1411impl Database<WasmBridgeBackend> {
1412    /// Open (or create) a SQLite database persisted to OPFS under `name`,
1413    /// degrading to an ephemeral in-memory session where OPFS is unavailable.
1414    pub async fn open(name: &str) -> Result<Self> {
1415        Self::open_with(name, false).await
1416    }
1417
1418    /// Open as [`open`](Self::open), but force an ephemeral in-memory session
1419    /// when `ephemeral` is true.
1420    pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
1421        let backend = WasmBridgeBackend::open_with(name, ephemeral)
1422            .await
1423            .map_err(DynoxideError::from)?;
1424        Ok(Self {
1425            inner: Arc::new(BackendMutex::new(backend)),
1426            tokens: Arc::new(TokenCaches::new()),
1427            vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
1428        })
1429    }
1430
1431    /// The active persistence mode: `"opfs"`, `"memory"`, or `"unknown"`.
1432    pub async fn persistence_mode(&self) -> String {
1433        self.backend().await.persistence_mode().to_string()
1434    }
1435
1436    /// Close the underlying SQLite connection. The operation-level engine
1437    /// calls this before re-opening, so the previous connection is released
1438    /// rather than leaked when a new database replaces it.
1439    pub async fn close(&self) -> Result<()> {
1440        self.backend()
1441            .await
1442            .close()
1443            .await
1444            .map_err(DynoxideError::from)
1445    }
1446
1447    /// Lock the single backend for the span of one handler call. The guard is
1448    /// held across the whole call so the operation (including any transaction)
1449    /// is atomic; the async mutex queues concurrent callers rather than
1450    /// deadlocking, and never poisons.
1451    ///
1452    /// `pub(crate)` so the operation-level [`wasm_api`](crate::wasm_api) engine
1453    /// can hold the lock across a whole `execute` dispatch, matching the
1454    /// per-handler atomicity of the wrappers below.
1455    pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
1456        self.inner.lock().await
1457    }
1458
1459    /// The idempotency caches this instance owns, for a dispatch that has to
1460    /// honour `ClientRequestToken`. Outlives any single call, so a replay finds
1461    /// the earlier one's result.
1462    pub(crate) fn token_caches(&self) -> &TokenCaches {
1463        &self.tokens
1464    }
1465
1466    /// The vector index creation lifecycle this instance owns, for a dispatch
1467    /// that has to report or enforce it. Outlives any single call, so an index
1468    /// armed by one dispatch is still creating when the next one looks.
1469    pub(crate) fn vector_lifecycle(&self) -> &VectorIndexLifecycle {
1470        &self.vector_lifecycle
1471    }
1472
1473    /// Create a new DynamoDB table.
1474    pub async fn create_table(
1475        &self,
1476        request: actions::create_table::CreateTableRequest,
1477    ) -> Result<actions::create_table::CreateTableResponse> {
1478        let backend = self.backend().await;
1479        actions::create_table::execute(&*backend, request).await
1480    }
1481
1482    /// Delete a DynamoDB table.
1483    pub async fn delete_table(
1484        &self,
1485        request: actions::delete_table::DeleteTableRequest,
1486    ) -> Result<actions::delete_table::DeleteTableResponse> {
1487        let backend = self.backend().await;
1488        actions::delete_table::execute(&*backend, request, &self.vector_lifecycle).await
1489    }
1490
1491    /// Describe a DynamoDB table.
1492    pub async fn describe_table(
1493        &self,
1494        request: actions::describe_table::DescribeTableRequest,
1495    ) -> Result<actions::describe_table::DescribeTableResponse> {
1496        let backend = self.backend().await;
1497        actions::describe_table::execute(&*backend, request, &self.vector_lifecycle).await
1498    }
1499
1500    /// List DynamoDB tables.
1501    pub async fn list_tables(
1502        &self,
1503        request: actions::list_tables::ListTablesRequest,
1504    ) -> Result<actions::list_tables::ListTablesResponse> {
1505        let backend = self.backend().await;
1506        actions::list_tables::execute(&*backend, request).await
1507    }
1508
1509    /// Put an item into a DynamoDB table.
1510    pub async fn put_item(
1511        &self,
1512        request: actions::put_item::PutItemRequest,
1513    ) -> Result<actions::put_item::PutItemResponse> {
1514        let backend = self.backend().await;
1515        actions::put_item::execute(&*backend, request).await
1516    }
1517
1518    /// Get an item from a DynamoDB table.
1519    pub async fn get_item(
1520        &self,
1521        request: actions::get_item::GetItemRequest,
1522    ) -> Result<actions::get_item::GetItemResponse> {
1523        let backend = self.backend().await;
1524        actions::get_item::execute(&*backend, request).await
1525    }
1526
1527    /// Delete an item from a DynamoDB table.
1528    pub async fn delete_item(
1529        &self,
1530        request: actions::delete_item::DeleteItemRequest,
1531    ) -> Result<actions::delete_item::DeleteItemResponse> {
1532        let backend = self.backend().await;
1533        actions::delete_item::execute(&*backend, request).await
1534    }
1535
1536    /// Query a DynamoDB table or secondary index.
1537    pub async fn query(
1538        &self,
1539        request: actions::query::QueryRequest,
1540    ) -> Result<actions::query::QueryResponse> {
1541        let backend = self.backend().await;
1542        actions::query::execute(&*backend, request).await
1543    }
1544
1545    /// Scan a DynamoDB table or secondary index.
1546    pub async fn scan(
1547        &self,
1548        request: actions::scan::ScanRequest,
1549    ) -> Result<actions::scan::ScanResponse> {
1550        let backend = self.backend().await;
1551        actions::scan::execute(&*backend, request).await
1552    }
1553
1554    /// Search a vector index, as [`Database::search_vectors`] on the native
1555    /// build. The scoring runs in the engine and the candidate load is one
1556    /// bridge crossing, so the answer is the same either side.
1557    pub async fn search_vectors(
1558        &self,
1559        request: actions::search_vectors::SearchVectorsRequest,
1560    ) -> Result<actions::search_vectors::SearchVectorsResponse> {
1561        let backend = self.backend().await;
1562        actions::search_vectors::execute(&*backend, request, &self.vector_lifecycle).await
1563    }
1564}
1565
1566#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1567mod tests {
1568    use super::*;
1569
1570    /// A database with one table and a snapshot of it, for the restore tests.
1571    fn database_with_snapshot(dir: &tempfile::TempDir) -> (Database, String) {
1572        let snapshot = dir.path().join("before.db");
1573        let snapshot = snapshot.to_str().unwrap().to_string();
1574        let db = Database::memory().unwrap();
1575        db.create_table(
1576            serde_json::from_value(serde_json::json!({
1577                "TableName": "tbl",
1578                "BillingMode": "PAY_PER_REQUEST",
1579                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1580                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1581            }))
1582            .unwrap(),
1583        )
1584        .unwrap();
1585        db.vacuum_into(&snapshot).unwrap();
1586        (db, snapshot)
1587    }
1588
1589    /// Stand in for a tokened transaction that has taken its cache (the first
1590    /// half of `run_idempotent`), let `restore` run on another thread, then
1591    /// take the storage lock (the transaction's second half). The storage step
1592    /// must not end up waiting on a restore that is itself waiting on the
1593    /// cache this thread holds, and the restore must still be waiting on that
1594    /// cache afterwards, or the check proved nothing.
1595    fn assert_restore_keeps_cache_then_storage_order<T: Send + 'static>(
1596        db: &Database,
1597        cache: &Mutex<TokenCache<T>>,
1598        restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
1599    ) {
1600        let held = cache.lock().unwrap();
1601        let (ready, is_ready) = std::sync::mpsc::channel();
1602        let restorer = {
1603            let db = db.clone();
1604            std::thread::spawn(move || {
1605                let _ = ready.send(());
1606                restore(db)
1607            })
1608        };
1609        is_ready.recv().unwrap();
1610        let (sent, got) = std::sync::mpsc::channel();
1611        {
1612            let db = db.clone();
1613            std::thread::spawn(move || {
1614                let _ = sent.send(db.with_storage(|_| Ok(())).is_ok());
1615            });
1616        }
1617        let storage_step = got.recv_timeout(std::time::Duration::from_secs(5));
1618        let restore_waited = !restorer.is_finished();
1619        drop(held);
1620        assert!(
1621            matches!(storage_step, Ok(true)),
1622            "a transaction holding its token cache must still get the storage lock while a restore waits"
1623        );
1624        assert!(
1625            restore_waited,
1626            "the restore must wait for the token cache the transaction holds"
1627        );
1628        restorer
1629            .join()
1630            .unwrap()
1631            .expect("the restore finishes once the cache is free");
1632    }
1633
1634    /// Hold the storage lock and let `restore` run on another thread until it
1635    /// is queued for storage, then check it already holds `cache`. This pins
1636    /// the order without timing: it waits on the storage lock's own count of
1637    /// waiters, so it fails every time on a restore that takes storage first.
1638    fn assert_restore_takes_cache_before_storage<T: Send + 'static>(
1639        db: &Database,
1640        cache: &Mutex<TokenCache<T>>,
1641        restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
1642    ) {
1643        let storage = db.inner.lock().unwrap();
1644        let restorer = {
1645            let db = db.clone();
1646            std::thread::spawn(move || restore(db))
1647        };
1648        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1649        while db.inner.waiting() == 0 {
1650            assert!(
1651                std::time::Instant::now() < deadline,
1652                "the restore never asked for the storage lock"
1653            );
1654            std::thread::yield_now();
1655        }
1656        let cache_was_free = cache.try_lock().is_ok();
1657        drop(storage);
1658        restorer
1659            .join()
1660            .unwrap()
1661            .expect("the restore finishes once storage is free");
1662        assert!(
1663            !cache_was_free,
1664            "a restore must hold the token caches before it waits for storage"
1665        );
1666    }
1667
1668    #[test]
1669    fn a_restore_cannot_deadlock_against_a_tokened_transaction() {
1670        let dir = tempfile::tempdir().unwrap();
1671        let (db, snapshot) = database_with_snapshot(&dir);
1672        let restore = |path: &String| {
1673            let path = path.clone();
1674            move |db: Database| db.restore_from(&path)
1675        };
1676        assert_restore_keeps_cache_then_storage_order(
1677            &db,
1678            &db.tokens.transact_write,
1679            restore(&snapshot),
1680        );
1681        assert_restore_keeps_cache_then_storage_order(
1682            &db,
1683            &db.tokens.execute_transaction,
1684            restore(&snapshot),
1685        );
1686        assert_restore_takes_cache_before_storage(
1687            &db,
1688            &db.tokens.transact_write,
1689            restore(&snapshot),
1690        );
1691        assert_restore_takes_cache_before_storage(
1692            &db,
1693            &db.tokens.execute_transaction,
1694            restore(&snapshot),
1695        );
1696    }
1697
1698    /// A restore from an in-memory copy of `db`, made now so the restore
1699    /// itself is all the returned closure does.
1700    #[cfg(feature = "mcp-server")]
1701    fn connection_restore(db: &Database) -> impl FnOnce(Database) -> Result<()> + Send + 'static {
1702        let copy = db.backup_to_memory().unwrap();
1703        move |db: Database| db.restore_from_connection(&copy)
1704    }
1705
1706    #[cfg(feature = "mcp-server")]
1707    #[test]
1708    fn a_connection_restore_cannot_deadlock_against_a_tokened_transaction() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let (db, _) = database_with_snapshot(&dir);
1711        assert_restore_keeps_cache_then_storage_order(
1712            &db,
1713            &db.tokens.transact_write,
1714            connection_restore(&db),
1715        );
1716        assert_restore_keeps_cache_then_storage_order(
1717            &db,
1718            &db.tokens.execute_transaction,
1719            connection_restore(&db),
1720        );
1721        assert_restore_takes_cache_before_storage(
1722            &db,
1723            &db.tokens.transact_write,
1724            connection_restore(&db),
1725        );
1726        assert_restore_takes_cache_before_storage(
1727            &db,
1728            &db.tokens.execute_transaction,
1729            connection_restore(&db),
1730        );
1731    }
1732
1733    #[test]
1734    fn a_restore_forgets_cached_transactions_and_vector_windows() {
1735        let dir = tempfile::tempdir().unwrap();
1736        let snapshot = dir.path().join("before.db");
1737        let snapshot = snapshot.to_str().unwrap();
1738        let db = Database::memory().unwrap();
1739        db.create_table(
1740            serde_json::from_value(serde_json::json!({
1741                "TableName": "tbl",
1742                "BillingMode": "PAY_PER_REQUEST",
1743                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1744                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1745            }))
1746            .unwrap(),
1747        )
1748        .unwrap();
1749        db.vacuum_into(snapshot).unwrap();
1750
1751        let put = || {
1752            serde_json::from_value::<actions::transact_write_items::TransactWriteItemsRequest>(
1753                serde_json::json!({
1754                    "ClientRequestToken": "same-token",
1755                    "TransactItems": [{"Put": {"TableName": "tbl", "Item": {"pk": {"S": "a"}}}}]
1756                }),
1757            )
1758            .unwrap()
1759        };
1760        db.transact_write_items(put()).unwrap();
1761        db.with_storage(|s| {
1762            db.vector_lifecycle.arm(s, "tbl", "v");
1763            Ok(())
1764        })
1765        .unwrap();
1766        assert!(!db.vector_lifecycle.is_empty());
1767
1768        db.restore_from(snapshot).unwrap();
1769        assert!(
1770            db.vector_lifecycle.is_empty(),
1771            "the restored tables' creation windows must not survive"
1772        );
1773
1774        // The same token after the restore runs again rather than replaying a
1775        // result computed against the data the restore replaced.
1776        db.transact_write_items(put()).unwrap();
1777        let got = db
1778            .get_item(
1779                serde_json::from_value(serde_json::json!({
1780                    "TableName": "tbl",
1781                    "Key": {"pk": {"S": "a"}}
1782                }))
1783                .unwrap(),
1784            )
1785            .unwrap();
1786        assert!(
1787            got.item.is_some(),
1788            "the retried transaction must have written"
1789        );
1790    }
1791
1792    #[test]
1793    fn an_unrelated_update_leaves_an_unsorted_stored_set_under_an_index_key_alone() {
1794        let db = Database::memory().unwrap();
1795        db.create_table(
1796            serde_json::from_value(serde_json::json!({
1797                "TableName": "tbl",
1798                "BillingMode": "PAY_PER_REQUEST",
1799                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
1800                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
1801            }))
1802            .unwrap(),
1803        )
1804        .unwrap();
1805        db.put_item(
1806            serde_json::from_value(serde_json::json!({
1807                "TableName": "tbl",
1808                "Item": {"pk": {"S": "k"}, "g": {"SS": ["a", "b"]}}
1809            }))
1810            .unwrap(),
1811        )
1812        .unwrap();
1813        // As a build before sets were sorted would have stored it.
1814        let stored: String = db
1815            .with_storage(|s| {
1816                s.conn().execute(
1817                    "UPDATE \"tbl\" SET item_json = replace(item_json, '\"SS\":[\"a\",\"b\"]', '\"SS\":[\"b\",\"a\"]') \
1818                     WHERE item_json LIKE '%\"SS\":[\"a\",\"b\"]%'",
1819                    [],
1820                )?;
1821                Ok(s.conn()
1822                    .query_row("SELECT item_json FROM \"tbl\"", [], |row| row.get(0))?)
1823            })
1824            .unwrap();
1825        assert!(
1826            stored.contains(r#""SS":["b","a"]"#),
1827            "the fixture must hold an unsorted set: {stored}"
1828        );
1829        // `g` becomes a GSI key; the set is the wrong type for it, so the
1830        // backfill leaves the item out of the index.
1831        db.update_table(
1832            serde_json::from_value(serde_json::json!({
1833                "TableName": "tbl",
1834                "AttributeDefinitions": [{"AttributeName": "g", "AttributeType": "S"}],
1835                "GlobalSecondaryIndexUpdates": [{"Create": {
1836                    "IndexName": "byG",
1837                    "KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}],
1838                    "Projection": {"ProjectionType": "ALL"}
1839                }}]
1840            }))
1841            .unwrap(),
1842        )
1843        .unwrap();
1844
1845        db.update_item(
1846            serde_json::from_value(serde_json::json!({
1847                "TableName": "tbl",
1848                "Key": {"pk": {"S": "k"}},
1849                "UpdateExpression": "SET note = :v",
1850                "ExpressionAttributeValues": {":v": {"S": "x"}}
1851            }))
1852            .unwrap(),
1853        )
1854        .expect("an update that doesn't touch g must not re-check g against the index");
1855    }
1856
1857    #[test]
1858    fn test_database_memory() {
1859        let db = Database::memory().unwrap();
1860        // Should be able to clone (Arc)
1861        let _db2 = db.clone();
1862    }
1863
1864    #[test]
1865    fn test_database_with_storage() {
1866        let db = Database::memory().unwrap();
1867        let tables = db.with_storage(|s| s.list_table_names()).unwrap();
1868        assert!(tables.is_empty());
1869    }
1870
1871    #[test]
1872    fn test_database_thread_safe() {
1873        let db = Database::memory().unwrap();
1874        let db2 = db.clone();
1875
1876        let handle =
1877            std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
1878
1879        let tables = handle.join().unwrap();
1880        assert!(tables.is_empty());
1881    }
1882
1883    #[test]
1884    fn test_native_database_alias_round_trips() {
1885        // The `NativeDatabase` alias is the default `Database<RusqliteBackend>`
1886        // and must drive the async handlers through the synchronous facade
1887        // transparently: a put/get round-trip behaves exactly as before.
1888        let db: NativeDatabase = Database::memory().unwrap();
1889
1890        db.create_table(actions::create_table::CreateTableRequest {
1891            table_name: "tbl".to_string(),
1892            key_schema: vec![types::KeySchemaElement {
1893                attribute_name: "pk".to_string(),
1894                key_type: types::KeyType::HASH,
1895            }],
1896            attribute_definitions: vec![types::AttributeDefinition {
1897                attribute_name: "pk".to_string(),
1898                attribute_type: types::ScalarAttributeType::S,
1899            }],
1900            ..Default::default()
1901        })
1902        .unwrap();
1903
1904        let mut item = HashMap::new();
1905        item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1906        db.put_item(actions::put_item::PutItemRequest {
1907            table_name: "tbl".to_string(),
1908            item,
1909            ..Default::default()
1910        })
1911        .unwrap();
1912
1913        let mut key = HashMap::new();
1914        key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
1915        let got = db
1916            .get_item(actions::get_item::GetItemRequest {
1917                table_name: "tbl".to_string(),
1918                key,
1919                ..Default::default()
1920            })
1921            .unwrap();
1922        assert_eq!(
1923            got.item.unwrap().get("pk"),
1924            Some(&AttributeValue::S("a".to_string()))
1925        );
1926    }
1927}
1928
1929/// `Database::sweep_ttl` taking the lock a page at a time, and handing it to
1930/// waiting requests between pages.
1931#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1932mod ttl_sweep_tests;
1933
1934/// Transactions that SQLite fails part way through, on a database that can't
1935/// grow.
1936#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
1937mod transaction_storage_error_tests;
1938
1939/// Cache primitives and the asynchronous driver.
1940///
1941/// These are private, so they are exercised here rather than from an
1942/// integration test. The public-facade behaviour both drivers must preserve
1943/// lives in `tests/execute_transaction.rs` and `tests/transactions.rs`.
1944#[cfg(test)]
1945mod idempotency_tests {
1946    use super::*;
1947    use std::cell::Cell;
1948
1949    const KEY: &str = "statements";
1950    const TOKEN: &str = "tok";
1951
1952    fn cache() -> Mutex<TokenCache<u32>> {
1953        Mutex::new(HashMap::new())
1954    }
1955
1956    fn hash() -> u64 {
1957        request_hash(&KEY)
1958    }
1959
1960    fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
1961    where
1962        F: std::future::Future<Output = Result<u32>>,
1963    {
1964        pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
1965    }
1966
1967    #[test]
1968    fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
1969        let cache = cache();
1970
1971        let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
1972            panic!("the first caller should have claimed the token");
1973        };
1974        assert!(matches!(
1975            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1976            TokenClaim::InFlight
1977        ));
1978
1979        record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
1980        assert!(matches!(
1981            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
1982            TokenClaim::Hit(7)
1983        ));
1984    }
1985
1986    #[test]
1987    fn only_one_caller_can_claim_a_token() {
1988        // Split the lookup and the claim into two acquisitions and this goes
1989        // red: several racing callers would each find the slot free. Sequential
1990        // calls would not catch that, so this has to be threaded.
1991        use std::sync::Barrier;
1992
1993        const N: usize = 16;
1994        let cache = cache();
1995        let barrier = Barrier::new(N);
1996
1997        let marked = std::thread::scope(|scope| {
1998            let handles: Vec<_> = (0..N)
1999                .map(|_| {
2000                    scope.spawn(|| {
2001                        barrier.wait();
2002                        matches!(
2003                            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2004                            TokenClaim::Marked(_)
2005                        )
2006                    })
2007                })
2008                .collect();
2009            handles
2010                .into_iter()
2011                .map(|h| h.join().unwrap())
2012                .filter(|claimed| *claimed)
2013                .count()
2014        });
2015
2016        assert_eq!(marked, 1);
2017    }
2018
2019    #[test]
2020    fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
2021        // A claim can expire while its call is still running, letting a second
2022        // caller take the token. The first must not then settle or release a
2023        // slot that is no longer its own.
2024        let cache = cache();
2025        let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2026            panic!("the first caller should have claimed the token");
2027        };
2028        let TokenClaim::Marked(second) =
2029            lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
2030        else {
2031            panic!("the expired claim should have been re-issued");
2032        };
2033        assert_ne!(first, second);
2034
2035        // The first call finishing must not overwrite the second's slot.
2036        record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
2037        assert!(matches!(
2038            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2039            TokenClaim::InFlight
2040        ));
2041
2042        // Nor must the first call failing release it.
2043        clear_claim(&cache, TOKEN, first).unwrap();
2044        assert!(matches!(
2045            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2046            TokenClaim::InFlight
2047        ));
2048
2049        // The caller that owns the claim still settles it.
2050        record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
2051        assert!(matches!(
2052            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2053            TokenClaim::Hit(2)
2054        ));
2055    }
2056
2057    /// A map in the request hashes by its contents, not by the order its keys
2058    /// come out in. Two `HashMap`s holding the same entries iterate in
2059    /// different orders, and serde_json keeps that order when a crate in the
2060    /// build turns on its `preserve_order` feature, so the digest sorts keys
2061    /// itself, at every depth. Otherwise a same-token retry of an identical
2062    /// request could be refused as a mismatch.
2063    #[test]
2064    fn a_request_hash_ignores_map_key_order() {
2065        use crate::types::AttributeValue;
2066        let item = |n: &str| -> HashMap<String, AttributeValue> {
2067            let inner: HashMap<String, AttributeValue> = (0..16)
2068                .map(|i| (format!("i{i}"), AttributeValue::N(n.to_string())))
2069                .collect();
2070            (0..16)
2071                .map(|i| (format!("k{i}"), AttributeValue::M(inner.clone())))
2072                .collect()
2073        };
2074        for _ in 0..8 {
2075            assert_eq!(
2076                request_hash(&vec![item("1")]),
2077                request_hash(&vec![item("1")])
2078            );
2079        }
2080        assert_ne!(
2081            request_hash(&vec![item("1")]),
2082            request_hash(&vec![item("2")])
2083        );
2084    }
2085
2086    #[test]
2087    fn a_different_request_under_the_same_token_mismatches_in_both_states() {
2088        let other = request_hash(&"different");
2089
2090        let cache = cache();
2091        let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2092            panic!("expected a free slot to claim");
2093        };
2094        assert!(matches!(
2095            lookup_or_claim(&cache, TOKEN, other).unwrap(),
2096            TokenClaim::Mismatch
2097        ));
2098
2099        record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
2100        assert!(matches!(
2101            lookup_or_claim(&cache, TOKEN, other).unwrap(),
2102            TokenClaim::Mismatch
2103        ));
2104    }
2105
2106    #[test]
2107    fn the_claim_lands_before_the_call_is_polled() {
2108        // Swapping the claim and the poll in the driver turns this red.
2109        let cache = cache();
2110        let claimed_first = Cell::new(false);
2111
2112        let out = drive(&cache, Some(TOKEN), async {
2113            // try_lock, not lock: a driver still holding the guard across the
2114            // call would hang here, and a hang reads worse than an assertion.
2115            let slots = cache
2116                .try_lock()
2117                .expect("the cache lock must be released before the call runs");
2118            claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
2119            Ok(7)
2120        });
2121
2122        assert_eq!(out.unwrap(), 7);
2123        assert!(claimed_first.get());
2124    }
2125
2126    #[test]
2127    fn a_caller_arriving_under_a_live_claim_does_not_execute() {
2128        let cache = cache();
2129        let second_ran = Cell::new(false);
2130
2131        let out = drive(&cache, Some(TOKEN), async {
2132            let second = drive(&cache, Some(TOKEN), async {
2133                second_ran.set(true);
2134                Ok(0)
2135            });
2136            // Pinned exactly: InternalServerError is also what a poisoned lock
2137            // produces, so the variant alone would not tell them apart.
2138            assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
2139            // The refusal must leave the claim alone; releasing it here would
2140            // let a third caller start a second execution.
2141            assert!(matches!(
2142                lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2143                TokenClaim::InFlight
2144            ));
2145            Ok(1)
2146        });
2147
2148        assert_eq!(out.unwrap(), 1);
2149        assert!(!second_ran.get(), "the second call must not run the work");
2150        // And the outer call still settles the slot it owns.
2151        assert!(matches!(
2152            lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
2153            TokenClaim::Hit(1)
2154        ));
2155    }
2156
2157    #[test]
2158    fn the_synchronous_driver_treats_a_live_claim_as_absent() {
2159        // The two drivers never share a cache today, so this cannot happen -
2160        // the synchronous driver holds its lock across the call and never
2161        // writes a claim. Pinned so that if they ever do share one, the
2162        // double-apply shows up here rather than in production.
2163        let cache = cache();
2164        lookup_or_claim(&cache, TOKEN, hash()).unwrap();
2165
2166        let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
2167        assert_eq!(
2168            out, 9,
2169            "a claimed slot is not treated as a replayable result"
2170        );
2171    }
2172
2173    #[test]
2174    fn a_settled_token_replays_without_re_executing() {
2175        let cache = cache();
2176        let runs = Cell::new(0);
2177
2178        for _ in 0..2 {
2179            let out = drive(&cache, Some(TOKEN), async {
2180                runs.set(runs.get() + 1);
2181                Ok(7)
2182            });
2183            assert_eq!(out.unwrap(), 7);
2184        }
2185        assert_eq!(runs.get(), 1);
2186    }
2187
2188    #[test]
2189    fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
2190        let cache = cache();
2191        let runs = Cell::new(0);
2192
2193        let first = drive(&cache, Some(TOKEN), async {
2194            runs.set(runs.get() + 1);
2195            Err(DynoxideError::ValidationException("no".into()))
2196        });
2197        assert!(first.is_err());
2198        assert!(
2199            cache.lock().unwrap().is_empty(),
2200            "the claim must be released"
2201        );
2202
2203        let second = drive(&cache, Some(TOKEN), async {
2204            runs.set(runs.get() + 1);
2205            Ok(7)
2206        });
2207        assert_eq!(second.unwrap(), 7);
2208        assert_eq!(runs.get(), 2);
2209    }
2210
2211    #[test]
2212    fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
2213        // A dropped future leaves its claim behind. Expiry is what stops that
2214        // wedging the token until the process restarts.
2215        let cache = cache();
2216        lookup_or_claim(&cache, TOKEN, hash()).unwrap();
2217
2218        assert!(matches!(
2219            lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
2220            TokenClaim::Marked(_)
2221        ));
2222    }
2223
2224    #[test]
2225    fn a_settled_token_stops_replaying_once_it_expires() {
2226        let cache = cache();
2227        let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
2228            panic!("expected a free slot to claim");
2229        };
2230        record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
2231
2232        // Inside the window it still replays.
2233        assert!(matches!(
2234            lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
2235            TokenClaim::Hit(7)
2236        ));
2237        // Past it, the token is free again.
2238        assert!(matches!(
2239            lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
2240            TokenClaim::Marked(_)
2241        ));
2242    }
2243
2244    #[test]
2245    fn concurrent_callers_under_one_token_execute_the_work_once() {
2246        // The asynchronous driver exists because it cannot hold a lock across
2247        // its call, so this is the property that matters most for it. The
2248        // synchronous driver has the same test in tests/execute_transaction.rs.
2249        use std::sync::Barrier;
2250        use std::sync::atomic::{AtomicUsize, Ordering};
2251
2252        const N: usize = 16;
2253        let cache = cache();
2254        let runs = AtomicUsize::new(0);
2255        let barrier = Barrier::new(N);
2256
2257        let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
2258            let handles: Vec<_> = (0..N)
2259                .map(|_| {
2260                    scope.spawn(|| {
2261                        barrier.wait();
2262                        drive(&cache, Some(TOKEN), async {
2263                            runs.fetch_add(1, Ordering::SeqCst);
2264                            Ok(7)
2265                        })
2266                    })
2267                })
2268                .collect();
2269            handles.into_iter().map(|h| h.join().unwrap()).collect()
2270        });
2271
2272        assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
2273        // Every caller either gets the value or is told a call is in flight;
2274        // none may run the work a second time.
2275        for outcome in outcomes {
2276            match outcome {
2277                Ok(v) => assert_eq!(v, 7),
2278                Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
2279            }
2280        }
2281    }
2282
2283    #[test]
2284    fn an_overlong_token_is_rejected_with_dynamodbs_message() {
2285        let token = "x".repeat(MAX_TOKEN_LEN + 1);
2286        let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
2287        assert_eq!(
2288            err.to_string(),
2289            format!(
2290                "1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
2291            )
2292        );
2293    }
2294
2295    #[test]
2296    fn a_tokenless_call_never_touches_the_cache() {
2297        let cache = cache();
2298        let runs = Cell::new(0);
2299
2300        for _ in 0..2 {
2301            drive(&cache, None, async {
2302                runs.set(runs.get() + 1);
2303                Ok(7)
2304            })
2305            .unwrap();
2306        }
2307        assert_eq!(runs.get(), 2);
2308        assert!(cache.lock().unwrap().is_empty());
2309    }
2310}