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