storage_kit/database_handle.rs
1//! Private implementation of the root database owner.
2
3use std::{fmt, future::Future, sync::Arc};
4
5#[cfg(feature = "surrealdb")]
6use crate::databases::surrealdb::backend::SurrealBackend;
7use crate::{
8 CapabilitySet, CatalogRequest, CatalogResponse, DataRequest, DataResponse, IndexRequest,
9 IndexResponse, QueryRequest, QueryResponse, StorageError,
10 databases::{config::DatabaseConfig, lancedb::backend::LanceBackend},
11};
12
13/// Portable data, catalog, query, and index behavior.
14///
15/// Implement this trait directly on a caller-owned type; `Clone` is not
16/// required. Operations are authoritative: an optional capability bit does not
17/// guarantee that an arbitrary request is supported. Unsupported operations
18/// fail before mutation, and responses preserve real ids, counts and score
19/// semantics. Supporting DTOs live in [`crate::database`].
20///
21/// # Caller-owned catalog example
22///
23/// This small test double validates one request and returns an empty catalog.
24/// It demonstrates direct trait consumption, not a database engine. Other
25/// operations reject explicitly; no native storage or service is constructed.
26///
27/// ```
28/// use storage_kit::{DatabaseBackend, StorageError};
29/// use storage_kit::database::CapabilitySet;
30/// use storage_kit::database::catalog::{CatalogRequest, CatalogResponse, ListCollectionsRequest};
31/// # use storage_kit::database::data::{DataRequest, DataResponse};
32/// # use storage_kit::database::query::{QueryRequest, QueryResponse};
33/// # use storage_kit::database::index::{IndexRequest, IndexResponse};
34///
35/// struct EmptyCatalog;
36/// impl DatabaseBackend for EmptyCatalog {
37/// fn capabilities(&self) -> CapabilitySet { CapabilitySet::empty() }
38/// async fn catalog(&self, request: CatalogRequest) -> Result<CatalogResponse, StorageError> {
39/// assert_eq!(request, CatalogRequest::ListCollections(ListCollectionsRequest {
40/// namespace: Vec::new(), limit: Some(10), start_after: None,
41/// }));
42/// Ok(CatalogResponse::Collections(Vec::new()))
43/// }
44/// # async fn data(&self, _: DataRequest) -> Result<DataResponse, StorageError> {
45/// # Err(StorageError::unsupported_capability("data", "catalog-only example"))
46/// # }
47/// # async fn query(&self, _: QueryRequest) -> Result<QueryResponse, StorageError> {
48/// # Err(StorageError::unsupported_capability("query", "catalog-only example"))
49/// # }
50/// # async fn index(&self, _: IndexRequest) -> Result<IndexResponse, StorageError> {
51/// # Err(StorageError::unsupported_capability("index", "catalog-only example"))
52/// # }
53/// }
54/// # tokio::runtime::Runtime::new()?.block_on(async {
55/// let database = EmptyCatalog;
56/// let response = database.catalog(CatalogRequest::ListCollections(ListCollectionsRequest {
57/// namespace: Vec::new(), limit: Some(10), start_after: None,
58/// })).await?;
59/// let CatalogResponse::Collections(collections) = response else {
60/// panic!("expected the catalog response");
61/// };
62/// assert!(collections.is_empty());
63/// assert!(database.capabilities().is_empty());
64/// # Ok::<(), StorageError>(())
65/// # })?;
66/// # Ok::<(), Box<dyn std::error::Error>>(())
67/// ```
68///
69/// An application may compose different implementations in its own exhaustive
70/// enum, forwarding requests and errors unchanged. See the
71/// [executable caller composition tests](https://github.com/jecsand838/ai-kit/blob/main/storage-kit/tests/mock_contracts.rs).
72/// The [local collection workflow](crate::database) uses real embedded storage.
73pub trait DatabaseBackend: Send + Sync + 'static {
74 /// Returns optional retained capabilities.
75 ///
76 /// Use this to discover optional operation families, not to identify the
77 /// implementation or assume an arbitrary request will succeed.
78 ///
79 /// See the [caller-owned catalog example](DatabaseBackend) for an executable example.
80 fn capabilities(&self) -> CapabilitySet;
81
82 /// Executes a retained catalog operation.
83 ///
84 /// Creation, opening, listing, deletion, and schema evolution remain distinct
85 /// from data writes and StateStore transactions.
86 ///
87 /// # Errors
88 ///
89 /// Returns [`StorageError`] for invalid scope or request data, unsupported
90 /// operations, and native connection, deadline, or execution failures. An
91 /// unsupported operation must fail before mutation.
92 ///
93 /// See the [caller-owned catalog example](DatabaseBackend) for an executable example.
94 fn catalog(
95 &self,
96 request: CatalogRequest,
97 ) -> impl Future<Output = Result<CatalogResponse, StorageError>> + Send + '_;
98
99 /// Executes a retained data operation.
100 ///
101 /// Ordinary writes are not rollback-capable multi-request transactions. A
102 /// native failure or timeout can leave an uncertain or partial outcome and
103 /// must not trigger automatic replay. Partition replacement retains its
104 /// explicit atomic contract.
105 ///
106 /// # Errors
107 ///
108 /// Returns [`StorageError`] for invalid input, unsupported request combinations,
109 /// read-only policy, serialization failures, and native execution/deadline
110 /// failures. Unsupported behavior must be rejected before mutation.
111 ///
112 /// See the [local collection workflow](crate::database) for an executable example.
113 fn data(
114 &self,
115 request: DataRequest,
116 ) -> impl Future<Output = Result<DataResponse, StorageError>> + Send + '_;
117
118 /// Executes a retained query operation.
119 ///
120 /// Return continuation tokens unchanged to the same applicable operation.
121 /// Scores preserve their explicit kind; a missing score is not zero.
122 ///
123 /// # Errors
124 ///
125 /// Returns [`StorageError`] for invalid scope, vectors, fields, filters, or
126 /// tuning, unsupported query combinations, and native execution/deadline
127 /// failures. Optional capability bits do not override request validation.
128 ///
129 /// # Examples
130 ///
131 /// ```
132 /// use storage_kit::{Database, DatabaseBackend};
133 /// use storage_kit::database::config::{DatabaseConfig, LanceConfig};
134 /// use storage_kit::database::StorageLocator;
135 /// use storage_kit::database::catalog::{CatalogRequest, CreateCollectionRequest, CreateCollectionMode};
136 /// use storage_kit::database::data::DataRecord;
137 /// use storage_kit::database::query::{QueryRequest, ScrollRequest, Projection};
138 /// # tokio::runtime::Runtime::new()?.block_on(async {
139 /// let database = Database::connect(DatabaseConfig::lance(LanceConfig::embedded_path("memory://"))).await?;
140 /// let locator = StorageLocator::new("documents");
141 /// database.catalog(CatalogRequest::CreateCollection(CreateCollectionRequest {
142 /// locator: locator.clone(), schema: None,
143 /// records: vec![DataRecord::without_id().json_field("text", serde_json::json!("example"))],
144 /// mode: CreateCollectionMode::Create,
145 /// })).await?;
146 /// let response = database.query(QueryRequest::Scroll(ScrollRequest {
147 /// locator, filter: None, projection: Projection::All, limit: Some(10),
148 /// offset: None, page_token: None,
149 /// })).await?;
150 /// assert_eq!(response.hits.len(), 1);
151 /// # Ok::<(), storage_kit::StorageError>(())
152 /// # })?;
153 /// # Ok::<(), Box<dyn std::error::Error>>(())
154 /// ```
155 fn query(
156 &self,
157 request: QueryRequest,
158 ) -> impl Future<Output = Result<QueryResponse, StorageError>> + Send + '_;
159
160 /// Executes a retained index operation.
161 ///
162 /// Creating an index and observing its readiness are separate operations.
163 /// Readiness requests must finish within both their requested wait bound and
164 /// the enclosing configured operation deadline.
165 ///
166 /// # Errors
167 ///
168 /// Returns [`StorageError`] for malformed index requests, unsupported kinds,
169 /// read-only policy, missing targets, unconfirmed completion, or native
170 /// execution/readiness deadline failures.
171 ///
172 /// # Examples
173 ///
174 /// ```
175 /// use storage_kit::{Database, DatabaseBackend};
176 /// use storage_kit::database::config::{DatabaseConfig, LanceConfig};
177 /// use storage_kit::database::StorageLocator;
178 /// use storage_kit::database::catalog::{CatalogRequest, CreateCollectionRequest, CreateCollectionMode};
179 /// use storage_kit::database::data::DataRecord;
180 /// use storage_kit::database::index::{IndexRequest, IndexResponse};
181 /// # tokio::runtime::Runtime::new()?.block_on(async {
182 /// let database = Database::connect(DatabaseConfig::lance(LanceConfig::embedded_path("memory://"))).await?;
183 /// let locator = StorageLocator::new("documents");
184 /// database.catalog(CatalogRequest::CreateCollection(CreateCollectionRequest {
185 /// locator: locator.clone(), schema: None,
186 /// records: vec![DataRecord::without_id().json_field("text", serde_json::json!("example"))],
187 /// mode: CreateCollectionMode::Create,
188 /// })).await?;
189 /// let response = database.index(IndexRequest::List { locator }).await?;
190 /// assert!(matches!(response, IndexResponse::Indexes(_)));
191 /// # Ok::<(), storage_kit::StorageError>(())
192 /// # })?;
193 /// # Ok::<(), Box<dyn std::error::Error>>(())
194 /// ```
195 fn index(
196 &self,
197 request: IndexRequest,
198 ) -> impl Future<Output = Result<IndexResponse, StorageError>> + Send + '_;
199}
200
201/// Cloneable opaque database handle.
202///
203/// Clones share the same opened database. Import [`DatabaseBackend`] to call
204/// its catalog, data, query, index, and capability methods. This supplied
205/// handle contains only built-ins; caller implementations retain their own
206/// concrete type and implement the trait directly.
207///
208/// # Examples
209///
210/// ```
211/// use storage_kit::{Database, DatabaseBackend};
212/// use storage_kit::database::config::{DatabaseConfig, LanceConfig};
213/// # tokio::runtime::Runtime::new()?.block_on(async {
214/// let database = Database::connect(DatabaseConfig::lance(LanceConfig::embedded_path("memory://"))).await?;
215/// let shared = database.clone();
216/// assert_eq!(database.capabilities(), shared.capabilities());
217/// # Ok::<(), storage_kit::StorageError>(())
218/// # })?;
219/// # Ok::<(), Box<dyn std::error::Error>>(())
220/// ```
221#[derive(Clone)]
222pub struct Database {
223 inner: Arc<DatabaseInner>,
224}
225
226enum DatabaseInner {
227 Lance(LanceBackend),
228 #[cfg(feature = "surrealdb")]
229 Surreal(SurrealBackend),
230}
231
232impl Database {
233 /// Opens a built-in database.
234 ///
235 /// The configuration is validated before backend resources are opened.
236 /// LanceDB is always available. SurrealDB requires `surrealdb`.
237 /// Disabled implementations return
238 /// [`StorageError::InvalidConfig`] before opening native resources.
239 /// The example uses an in-memory LanceDB instance and needs no service.
240 ///
241 /// # Errors
242 ///
243 /// Returns [`StorageError::InvalidConfig`] for invalid configuration or
244 /// required capabilities the selected backend cannot provide, or the
245 /// owning connection, I/O, or timeout error.
246 ///
247 /// # Examples
248 ///
249 /// ```
250 /// use storage_kit::{Database, DatabaseBackend};
251 /// use storage_kit::database::config::{DatabaseConfig, LanceConfig};
252 /// use storage_kit::database::CapabilitySet;
253 /// # tokio::runtime::Runtime::new()?.block_on(async {
254 /// let database = Database::connect(DatabaseConfig::lance(LanceConfig::embedded_path("memory://"))).await?;
255 /// assert!(database.capabilities().contains(CapabilitySet::FILTERING));
256 /// # Ok::<(), storage_kit::StorageError>(())
257 /// # })?;
258 /// # Ok::<(), Box<dyn std::error::Error>>(())
259 /// ```
260 pub async fn connect(config: DatabaseConfig) -> Result<Self, StorageError> {
261 config.validate()?;
262 match config {
263 DatabaseConfig::Lance { config, common } => LanceBackend::connect(common, config)
264 .await
265 .map(|backend| Self {
266 inner: Arc::new(DatabaseInner::Lance(backend)),
267 }),
268 DatabaseConfig::Surreal { config, common } => {
269 if cfg!(feature = "surrealdb") {
270 #[cfg(feature = "surrealdb")]
271 return SurrealBackend::connect(common, config)
272 .await
273 .map(|backend| Self {
274 inner: Arc::new(DatabaseInner::Surreal(backend)),
275 });
276 }
277 drop((config, common));
278 Err(StorageError::InvalidConfig(
279 "SurrealDB requires the `surrealdb` feature".into(),
280 ))
281 }
282 }
283 }
284}
285
286impl fmt::Debug for Database {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 f.write_str("Database { .. }")
289 }
290}
291
292impl DatabaseBackend for Database {
293 fn capabilities(&self) -> CapabilitySet {
294 self.inner.capabilities()
295 }
296
297 // Keep the declared Send bound opaque to callers so their future proofs do
298 // not recursively expand the native database query implementation.
299 fn catalog(
300 &self,
301 request: CatalogRequest,
302 ) -> impl Future<Output = Result<CatalogResponse, StorageError>> + Send + '_ {
303 self.inner.catalog(request)
304 }
305
306 fn data(
307 &self,
308 request: DataRequest,
309 ) -> impl Future<Output = Result<DataResponse, StorageError>> + Send + '_ {
310 self.inner.data(request)
311 }
312
313 fn query(
314 &self,
315 request: QueryRequest,
316 ) -> impl Future<Output = Result<QueryResponse, StorageError>> + Send + '_ {
317 self.inner.query(request)
318 }
319
320 fn index(
321 &self,
322 request: IndexRequest,
323 ) -> impl Future<Output = Result<IndexResponse, StorageError>> + Send + '_ {
324 self.inner.index(request)
325 }
326}
327
328impl DatabaseBackend for DatabaseInner {
329 fn capabilities(&self) -> CapabilitySet {
330 match self {
331 Self::Lance(backend) => backend.capabilities(),
332 #[cfg(feature = "surrealdb")]
333 Self::Surreal(backend) => backend.capabilities(),
334 }
335 }
336
337 async fn catalog(&self, request: CatalogRequest) -> Result<CatalogResponse, StorageError> {
338 match self {
339 Self::Lance(backend) => backend.catalog(request).await,
340 #[cfg(feature = "surrealdb")]
341 Self::Surreal(backend) => backend.catalog(request).await,
342 }
343 }
344
345 async fn data(&self, request: DataRequest) -> Result<DataResponse, StorageError> {
346 request.validate()?;
347 match self {
348 Self::Lance(backend) => backend.data(request).await,
349 #[cfg(feature = "surrealdb")]
350 Self::Surreal(backend) => backend.data(request).await,
351 }
352 }
353
354 async fn query(&self, request: QueryRequest) -> Result<QueryResponse, StorageError> {
355 match self {
356 Self::Lance(backend) => backend.query(request).await,
357 #[cfg(feature = "surrealdb")]
358 Self::Surreal(backend) => backend.query(request).await,
359 }
360 }
361
362 async fn index(&self, request: IndexRequest) -> Result<IndexResponse, StorageError> {
363 request.validate()?;
364 match self {
365 Self::Lance(backend) => backend.index(request).await,
366 #[cfg(feature = "surrealdb")]
367 Self::Surreal(backend) => backend.index(request).await,
368 }
369 }
370}