Skip to main content

dynoxide/
lib.rs

1//! # Dynoxide
2//!
3//! A lightweight, embeddable DynamoDB emulator backed by SQLite.
4//!
5//! ```rust
6//! use dynoxide::Database;
7//!
8//! let db = Database::memory().unwrap();
9//! ```
10
11#[cfg(all(feature = "native-sqlite", feature = "_has-encryption"))]
12compile_error!(
13    "Features `native-sqlite` and `encryption`/`encryption-cc` are mutually exclusive.\n\
14     If you ran `cargo install`, use:\n  \
15     cargo install dynoxide-rs --no-default-features --features encrypted-server\n\
16     If using as a library dependency, set `default-features = false` \
17     and enable only one backend."
18);
19
20#[cfg(all(feature = "encryption", feature = "encryption-cc"))]
21compile_error!(
22    "Features `encryption` and `encryption-cc` are mutually exclusive. \
23     Use `encryption` for vendored OpenSSL or `encryption-cc` for Apple CommonCrypto."
24);
25
26#[cfg(all(feature = "encryption-cc", not(target_vendor = "apple")))]
27compile_error!(
28    "The `encryption-cc` feature is intended for Apple platforms only (CommonCrypto). \
29     Use the `encryption` feature for vendored OpenSSL on non-Apple platforms."
30);
31
32#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
33compile_error!(
34    "Either `native-sqlite`, `encryption`, or `encryption-cc` feature must be enabled. \
35     Default features include `native-sqlite`. If you used \
36     `default-features = false`, add one of these features."
37);
38
39pub mod actions;
40pub mod errors;
41pub mod expressions;
42#[cfg(feature = "import")]
43pub mod import;
44#[doc(hidden)]
45pub mod macros;
46#[cfg(feature = "mcp-server")]
47pub mod mcp;
48pub mod partiql;
49pub mod schema;
50#[cfg(feature = "http-server")]
51pub mod server;
52#[cfg(feature = "mcp-server")]
53pub(crate) mod snapshots;
54pub mod storage;
55pub mod streams;
56pub mod ttl;
57pub mod types;
58pub mod validation;
59
60#[doc(hidden)]
61pub use macros::ItemInsert;
62
63use std::collections::HashMap;
64use std::sync::{Arc, Mutex};
65use std::time::Instant;
66
67pub use errors::{DynoxideError, Result};
68pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
69pub use types::{AttributeValue, ConversionError, Item};
70
71/// Options for `Database::import_items()`.
72#[derive(Debug, Clone, Default)]
73pub struct ImportOptions {
74    /// Whether to record stream events for imported items. Default: false.
75    pub record_streams: bool,
76    /// Whether to set `cached_at` to the current timestamp. Default: false.
77    pub set_cached_at: bool,
78}
79
80/// Result of a bulk import operation.
81#[derive(Debug, Clone)]
82pub struct ImportResult {
83    /// Number of items imported.
84    pub items_imported: usize,
85    /// Total bytes imported (sum of item_size values).
86    pub bytes_imported: usize,
87}
88
89/// Cached transaction response with timestamp and request hash for idempotency.
90type TokenCache = HashMap<
91    String,
92    (
93        Instant,
94        u64,
95        actions::transact_write_items::TransactWriteItemsResponse,
96    ),
97>;
98
99/// The main entry point for the DynamoDB emulator.
100///
101/// Wraps a SQLite-backed storage layer and provides DynamoDB-compatible
102/// operations. Thread-safe via `Arc<Mutex<>>` — clone freely across threads.
103#[derive(Clone)]
104pub struct Database {
105    inner: Arc<Mutex<storage::Storage>>,
106    idempotency_tokens: Arc<Mutex<TokenCache>>,
107}
108
109impl Database {
110    /// Open a persistent database at the given path.
111    pub fn new(path: &str) -> Result<Self> {
112        let storage = storage::Storage::new(path)?;
113        Ok(Self {
114            inner: Arc::new(Mutex::new(storage)),
115            idempotency_tokens: Arc::new(Mutex::new(HashMap::new())),
116        })
117    }
118
119    /// Open or create an encrypted database at the given path.
120    ///
121    /// The key must be a 64-character hex string representing a 32-byte key.
122    /// Example: `"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"`
123    ///
124    /// The key is passed to SQLCipher via `PRAGMA key`. The database file is
125    /// encrypted at rest using AES-256-CBC.
126    ///
127    /// # Security
128    ///
129    /// This function borrows the key as `&str` and cannot zeroize the caller's
130    /// copy. The caller is responsible for zeroizing owned key material after
131    /// this call returns (e.g., by using `zeroize::Zeroizing<String>`).
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if:
136    /// - The key format is invalid (not 64 hex characters)
137    /// - The database exists but was created without encryption
138    /// - The database exists but the key is wrong
139    #[cfg(feature = "_has-encryption")]
140    pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
141        if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
142            return Err(DynoxideError::ValidationException(
143                "Encryption key must be a 64-character hex string (32 bytes)".to_string(),
144            ));
145        }
146
147        let storage = storage::Storage::new_encrypted(path, key)?;
148        Ok(Self {
149            inner: Arc::new(Mutex::new(storage)),
150            idempotency_tokens: Arc::new(Mutex::new(HashMap::new())),
151        })
152    }
153
154    /// Open an in-memory database (for tests and ephemeral use).
155    pub fn memory() -> Result<Self> {
156        let storage = storage::Storage::memory()?;
157        Ok(Self {
158            inner: Arc::new(Mutex::new(storage)),
159            idempotency_tokens: Arc::new(Mutex::new(HashMap::new())),
160        })
161    }
162
163    /// Execute a closure with exclusive access to the storage layer.
164    pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
165    where
166        F: FnOnce(&storage::Storage) -> Result<T>,
167    {
168        let guard = self
169            .inner
170            .lock()
171            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
172        f(&guard)
173    }
174
175    /// Execute a closure with mutable exclusive access to the storage layer.
176    pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
177    where
178        F: FnOnce(&mut storage::Storage) -> Result<T>,
179    {
180        let mut guard = self
181            .inner
182            .lock()
183            .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
184        f(&mut guard)
185    }
186
187    // -------------------------------------------------------------------
188    // Table operations
189    // -------------------------------------------------------------------
190
191    /// Create a new DynamoDB table.
192    pub fn create_table(
193        &self,
194        request: actions::create_table::CreateTableRequest,
195    ) -> Result<actions::create_table::CreateTableResponse> {
196        self.with_storage(|s| actions::create_table::execute(s, request))
197    }
198
199    /// Delete a DynamoDB table.
200    pub fn delete_table(
201        &self,
202        request: actions::delete_table::DeleteTableRequest,
203    ) -> Result<actions::delete_table::DeleteTableResponse> {
204        self.with_storage(|s| actions::delete_table::execute(s, request))
205    }
206
207    /// Describe a DynamoDB table.
208    pub fn describe_table(
209        &self,
210        request: actions::describe_table::DescribeTableRequest,
211    ) -> Result<actions::describe_table::DescribeTableResponse> {
212        self.with_storage(|s| actions::describe_table::execute(s, request))
213    }
214
215    /// Update a DynamoDB table (add/remove GSIs).
216    pub fn update_table(
217        &self,
218        request: actions::update_table::UpdateTableRequest,
219    ) -> Result<actions::update_table::UpdateTableResponse> {
220        self.with_storage(|s| actions::update_table::execute(s, request))
221    }
222
223    /// List DynamoDB tables.
224    pub fn list_tables(
225        &self,
226        request: actions::list_tables::ListTablesRequest,
227    ) -> Result<actions::list_tables::ListTablesResponse> {
228        self.with_storage(|s| actions::list_tables::execute(s, request))
229    }
230
231    // -------------------------------------------------------------------
232    // Tags
233    // -------------------------------------------------------------------
234
235    /// Add tags to a DynamoDB table.
236    pub fn tag_resource(
237        &self,
238        request: actions::tag_resource::TagResourceRequest,
239    ) -> Result<actions::tag_resource::TagResourceResponse> {
240        self.with_storage(|s| actions::tag_resource::execute(s, request))
241    }
242
243    /// Remove tags from a DynamoDB table.
244    pub fn untag_resource(
245        &self,
246        request: actions::untag_resource::UntagResourceRequest,
247    ) -> Result<actions::untag_resource::UntagResourceResponse> {
248        self.with_storage(|s| actions::untag_resource::execute(s, request))
249    }
250
251    /// List tags for a DynamoDB table.
252    pub fn list_tags_of_resource(
253        &self,
254        request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
255    ) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
256        self.with_storage(|s| actions::list_tags_of_resource::execute(s, request))
257    }
258
259    // -------------------------------------------------------------------
260    // Item operations
261    // -------------------------------------------------------------------
262
263    /// Put an item into a DynamoDB table.
264    pub fn put_item(
265        &self,
266        request: actions::put_item::PutItemRequest,
267    ) -> Result<actions::put_item::PutItemResponse> {
268        self.with_storage(|s| actions::put_item::execute(s, request))
269    }
270
271    /// Get an item from a DynamoDB table.
272    pub fn get_item(
273        &self,
274        request: actions::get_item::GetItemRequest,
275    ) -> Result<actions::get_item::GetItemResponse> {
276        self.with_storage(|s| actions::get_item::execute(s, request))
277    }
278
279    /// Delete an item from a DynamoDB table.
280    pub fn delete_item(
281        &self,
282        request: actions::delete_item::DeleteItemRequest,
283    ) -> Result<actions::delete_item::DeleteItemResponse> {
284        self.with_storage(|s| actions::delete_item::execute(s, request))
285    }
286
287    /// Update an item in a DynamoDB table.
288    pub fn update_item(
289        &self,
290        request: actions::update_item::UpdateItemRequest,
291    ) -> Result<actions::update_item::UpdateItemResponse> {
292        self.with_storage(|s| actions::update_item::execute(s, request))
293    }
294
295    // -------------------------------------------------------------------
296    // Batch operations
297    // -------------------------------------------------------------------
298
299    /// Batch get items from one or more DynamoDB tables.
300    pub fn batch_get_item(
301        &self,
302        request: actions::batch_get_item::BatchGetItemRequest,
303    ) -> Result<actions::batch_get_item::BatchGetItemResponse> {
304        self.with_storage(|s| actions::batch_get_item::execute(s, request))
305    }
306
307    /// Batch write items to one or more DynamoDB tables.
308    pub fn batch_write_item(
309        &self,
310        request: actions::batch_write_item::BatchWriteItemRequest,
311    ) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
312        self.with_storage(|s| actions::batch_write_item::execute(s, request))
313    }
314
315    /// Import items in bulk, bypassing per-item size validation.
316    ///
317    /// All items are inserted in a single transaction. If any item fails,
318    /// the entire import is rolled back. Items with duplicate keys within
319    /// the batch are resolved by last-write-wins (later items in the vec
320    /// overwrite earlier items with the same primary key).
321    ///
322    /// GSI entries are maintained: items with GSI key attributes are
323    /// inserted into the appropriate GSI tables. Items missing GSI key
324    /// attributes are silently omitted from the GSI (sparse GSI behavior,
325    /// matching DynamoDB semantics).
326    ///
327    /// Stream records are NOT generated by default. Use
328    /// `ImportOptions { record_streams: true, .. }` if stream recording is needed.
329    pub fn import_items(
330        &self,
331        table_name: &str,
332        items: Vec<Item>,
333        options: ImportOptions,
334    ) -> Result<ImportResult> {
335        self.with_storage(|s| actions::import_items::execute(s, table_name, items, &options))
336    }
337
338    /// Import items in bulk, skipping GSI DELETE-before-INSERT.
339    ///
340    /// Same as `import_items` but assumes the database is fresh (no
341    /// pre-existing rows), so GSI cleanup deletes are skipped entirely.
342    /// This eliminates the dominant bottleneck for large imports.
343    #[cfg(feature = "import")]
344    pub(crate) fn import_items_fresh(
345        &self,
346        table_name: &str,
347        items: Vec<Item>,
348        options: ImportOptions,
349    ) -> Result<ImportResult> {
350        self.with_storage(|s| {
351            actions::import_items::execute_skip_gsi_deletes(s, table_name, items, &options)
352        })
353    }
354
355    // -------------------------------------------------------------------
356    // Bulk loading
357    // -------------------------------------------------------------------
358
359    /// Set aggressive SQLite PRAGMAs for bulk loading.
360    ///
361    /// Only safe when data loss on crash is acceptable (e.g., fresh import).
362    /// Call `disable_bulk_loading()` after the import to restore normal settings.
363    pub fn enable_bulk_loading(&self) -> Result<()> {
364        self.with_storage(|s| s.enable_bulk_loading())
365    }
366
367    /// Restore normal SQLite PRAGMAs after bulk loading.
368    pub fn disable_bulk_loading(&self) -> Result<()> {
369        self.with_storage(|s| s.disable_bulk_loading())
370    }
371
372    // -------------------------------------------------------------------
373    // Query & Scan
374    // -------------------------------------------------------------------
375
376    /// Query a DynamoDB table.
377    pub fn query(
378        &self,
379        request: actions::query::QueryRequest,
380    ) -> Result<actions::query::QueryResponse> {
381        self.with_storage(|s| actions::query::execute(s, request))
382    }
383
384    /// Scan a DynamoDB table.
385    pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
386        self.with_storage(|s| actions::scan::execute(s, request))
387    }
388
389    // -------------------------------------------------------------------
390    // Transactions
391    // -------------------------------------------------------------------
392
393    /// Execute a transactional write (up to 100 actions, all-or-nothing).
394    pub fn transact_write_items(
395        &self,
396        request: actions::transact_write_items::TransactWriteItemsRequest,
397    ) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
398        const TOKEN_EXPIRY_SECS: u64 = 600; // 10 minutes
399        const MAX_TOKEN_LEN: usize = 36;
400
401        // Validate token length
402        if let Some(ref token) = request.client_request_token {
403            if token.len() > MAX_TOKEN_LEN {
404                return Err(DynoxideError::ValidationException(format!(
405                    "1 validation error detected: Value '{}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {}",
406                    token, MAX_TOKEN_LEN
407                )));
408            }
409        }
410
411        // Compute request hash for idempotency comparison.
412        // Normalise via serde_json::Value (uses BTreeMap internally) to ensure
413        // deterministic key ordering regardless of HashMap iteration order.
414        let request_hash = if request.client_request_token.is_some() {
415            use std::hash::{Hash, Hasher};
416            let normalised = serde_json::to_value(&request.transact_items)
417                .and_then(|v| serde_json::to_vec(&v))
418                .unwrap_or_default();
419            let mut hasher = std::collections::hash_map::DefaultHasher::new();
420            normalised.hash(&mut hasher);
421            hasher.finish()
422        } else {
423            0
424        };
425
426        // Check idempotency cache
427        if let Some(ref token) = request.client_request_token {
428            let mut cache = self
429                .idempotency_tokens
430                .lock()
431                .map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
432            // Evict expired entries
433            cache.retain(|_, (ts, _, _)| ts.elapsed().as_secs() < TOKEN_EXPIRY_SECS);
434            if let Some((_, cached_hash, resp)) = cache.get(token) {
435                if *cached_hash != request_hash {
436                    return Err(DynoxideError::IdempotentParameterMismatchException(
437                        "An error occurred (IdempotentParameterMismatchException)".to_string(),
438                    ));
439                }
440                return Ok(resp.clone());
441            }
442        }
443
444        let resp =
445            self.with_storage(|s| actions::transact_write_items::execute(s, request.clone()))?;
446
447        // Cache the response if token was provided
448        if let Some(ref token) = request.client_request_token {
449            if let Ok(mut cache) = self.idempotency_tokens.lock() {
450                cache.insert(token.clone(), (Instant::now(), request_hash, resp.clone()));
451            }
452        }
453
454        Ok(resp)
455    }
456
457    /// Execute a transactional read (up to 100 gets).
458    pub fn transact_get_items(
459        &self,
460        request: actions::transact_get_items::TransactGetItemsRequest,
461    ) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
462        self.with_storage(|s| actions::transact_get_items::execute(s, request))
463    }
464
465    // -------------------------------------------------------------------
466    // Streams
467    // -------------------------------------------------------------------
468
469    /// List DynamoDB Streams.
470    pub fn list_streams(
471        &self,
472        request: actions::list_streams::ListStreamsRequest,
473    ) -> Result<actions::list_streams::ListStreamsResponse> {
474        self.with_storage(|s| actions::list_streams::execute(s, request))
475    }
476
477    /// Describe a DynamoDB Stream.
478    pub fn describe_stream(
479        &self,
480        request: actions::describe_stream::DescribeStreamRequest,
481    ) -> Result<actions::describe_stream::DescribeStreamResponse> {
482        self.with_storage(|s| actions::describe_stream::execute(s, request))
483    }
484
485    /// Get a shard iterator.
486    pub fn get_shard_iterator(
487        &self,
488        request: actions::get_shard_iterator::GetShardIteratorRequest,
489    ) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
490        self.with_storage(|s| actions::get_shard_iterator::execute(s, request))
491    }
492
493    /// Get stream records.
494    pub fn get_records(
495        &self,
496        request: actions::get_records::GetRecordsRequest,
497    ) -> Result<actions::get_records::GetRecordsResponse> {
498        self.with_storage(|s| actions::get_records::execute(s, request))
499    }
500
501    // -------------------------------------------------------------------
502    // TTL
503    // -------------------------------------------------------------------
504
505    /// Update time to live configuration.
506    pub fn update_time_to_live(
507        &self,
508        request: actions::update_time_to_live::UpdateTimeToLiveRequest,
509    ) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
510        self.with_storage(|s| actions::update_time_to_live::execute(s, request))
511    }
512
513    /// Describe time to live configuration.
514    pub fn describe_time_to_live(
515        &self,
516        request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
517    ) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
518        self.with_storage(|s| actions::describe_time_to_live::execute(s, request))
519    }
520
521    /// Run a TTL sweep, deleting expired items from all TTL-enabled tables.
522    /// Returns the number of items deleted.
523    pub fn sweep_ttl(&self) -> Result<usize> {
524        self.with_storage(ttl::sweep_expired_items)
525    }
526
527    // -------------------------------------------------------------------
528    // PartiQL
529    // -------------------------------------------------------------------
530
531    /// Execute a single PartiQL statement.
532    pub fn execute_statement(
533        &self,
534        request: actions::execute_statement::ExecuteStatementRequest,
535    ) -> Result<actions::execute_statement::ExecuteStatementResponse> {
536        self.with_storage(|s| actions::execute_statement::execute(s, request))
537    }
538
539    /// Execute PartiQL statements transactionally (all-or-nothing).
540    pub fn execute_transaction(
541        &self,
542        request: actions::execute_transaction::ExecuteTransactionRequest,
543    ) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
544        self.with_storage(|s| actions::execute_transaction::execute(s, request))
545    }
546
547    /// Execute a batch of PartiQL statements.
548    pub fn batch_execute_statement(
549        &self,
550        request: actions::batch_execute_statement::BatchExecuteStatementRequest,
551    ) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
552        self.with_storage(|s| actions::batch_execute_statement::execute(s, request))
553    }
554
555    // -------------------------------------------------------------------
556    // Cache tracking
557    // -------------------------------------------------------------------
558
559    /// Update the `cached_at` timestamp for a single item.
560    ///
561    /// Used by cache layers to track when items were last fetched from a
562    /// remote source. The timestamp is a Unix epoch in seconds (f64).
563    pub fn touch_cached_at(
564        &self,
565        table_name: &str,
566        pk: &str,
567        sk: &str,
568        timestamp: f64,
569    ) -> Result<()> {
570        self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
571    }
572
573    /// Get items ordered by `cached_at` (oldest first) for LRU eviction.
574    ///
575    /// Returns `(pk, sk, item_size)` tuples. Items with NULL `cached_at`
576    /// are excluded (they were never cached from a remote source).
577    pub fn get_lru_items(
578        &self,
579        table_name: &str,
580        limit: usize,
581    ) -> Result<Vec<(String, String, i64)>> {
582        self.with_storage(|s| s.get_lru_items(table_name, limit))
583    }
584
585    // -------------------------------------------------------------------
586    // Introspection
587    // -------------------------------------------------------------------
588
589    /// Get the database file path, or `None` for in-memory databases.
590    pub fn db_path(&self) -> Result<Option<String>> {
591        self.with_storage(|s| Ok(s.db_path()))
592    }
593
594    /// Get the total database size in bytes.
595    pub fn db_size_bytes(&self) -> Result<u64> {
596        self.with_storage(|s| s.db_size_bytes())
597    }
598
599    /// Count the number of DynamoDB tables.
600    pub fn table_count(&self) -> Result<usize> {
601        self.with_storage(|s| s.table_count())
602    }
603
604    /// Get per-table statistics: name, item count, and approximate size in bytes.
605    pub fn table_stats(&self) -> Result<Vec<TableStats>> {
606        self.with_storage(|s| s.table_stats())
607    }
608
609    /// Get metadata for a specific table (key schema, GSIs, TTL config, etc.).
610    pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
611        self.with_storage(|s| s.get_table_metadata(table_name))
612    }
613
614    /// Get combined database info atomically in a single lock acquisition.
615    ///
616    /// Returns path, size, table count, and per-table stats + metadata.
617    /// Avoids the consistency issues of calling individual methods separately.
618    pub fn database_info(&self) -> Result<DatabaseInfo> {
619        self.with_storage(|s| s.database_info())
620    }
621
622    // -------------------------------------------------------------------
623    // Snapshot operations
624    // -------------------------------------------------------------------
625
626    /// Run VACUUM to compact the database file in place.
627    pub fn vacuum(&self) -> Result<()> {
628        self.with_storage(|s| s.vacuum())
629    }
630
631    /// Create a snapshot of the database by copying it to the given path.
632    ///
633    /// Uses SQLite's `VACUUM INTO` which works for both in-memory and
634    /// file-backed databases. The snapshot is a standalone SQLite file.
635    pub fn vacuum_into(&self, path: &str) -> Result<()> {
636        self.with_storage(|s| s.vacuum_into(path))
637    }
638
639    /// Restore the database from a snapshot file.
640    ///
641    /// Uses SQLite's backup API to replace the current database contents
642    /// with the snapshot. Works for both in-memory and file-backed databases.
643    /// The backup is atomic — either all pages are copied or none are.
644    pub fn restore_from(&self, path: &str) -> Result<()> {
645        self.with_storage_mut(|s| s.restore_from(path))
646    }
647
648    /// Backup the current database to a new in-memory SQLite connection.
649    ///
650    /// Returns an owned `Connection` holding a complete copy. Used for
651    /// in-memory snapshot storage — no filesystem side-effects.
652    #[cfg(feature = "mcp-server")]
653    pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
654        self.with_storage(|s| s.backup_to_memory())
655    }
656
657    /// Restore the database from an in-memory SQLite connection.
658    ///
659    /// Replaces current contents with the source connection's data.
660    #[cfg(feature = "mcp-server")]
661    pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
662        self.with_storage_mut(|s| s.restore_from_connection(source))
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn test_database_memory() {
672        let db = Database::memory().unwrap();
673        // Should be able to clone (Arc)
674        let _db2 = db.clone();
675    }
676
677    #[test]
678    fn test_database_with_storage() {
679        let db = Database::memory().unwrap();
680        let tables = db.with_storage(|s| s.list_table_names()).unwrap();
681        assert!(tables.is_empty());
682    }
683
684    #[test]
685    fn test_database_thread_safe() {
686        let db = Database::memory().unwrap();
687        let db2 = db.clone();
688
689        let handle =
690            std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
691
692        let tables = handle.join().unwrap();
693        assert!(tables.is_empty());
694    }
695}