Skip to main content

git_vdb/
store.rs

1//! A small persistent facade for common embedded vector-database operations.
2
3use crate::{
4    Collection, CollectionConfig, Database, DeleteSelector, Error, GetRequest, Point, PointId,
5    Query, Record, Result, ScoredPoint, WriteResult,
6};
7use git2::ErrorCode;
8use std::fs;
9use std::path::Path;
10use std::thread;
11
12const WRITE_ATTEMPTS: usize = 8;
13
14/// Opens or creates a persistent embedded vector database.
15///
16/// Existing bare and non-bare Git repositories are opened as-is. A missing path
17/// or existing empty directory is initialized as a bare repository. Existing
18/// nonempty directories that are not Git repositories are rejected.
19pub fn open(path: impl AsRef<Path>) -> Result<Store> {
20    Store::open(path)
21}
22
23/// A persistent embedded vector database with lazy collection handles.
24#[derive(Clone, Debug)]
25pub struct Store {
26    database: Database,
27}
28
29/// A named collection that is created with an inferred dimension on first use.
30#[derive(Clone, Debug)]
31pub struct CollectionHandle {
32    database: Database,
33    name: String,
34}
35
36impl Store {
37    /// Opens or safely creates a database at `path`.
38    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
39        let path = path.as_ref();
40        let database = match Database::open(path) {
41            Ok(database) => database,
42            Err(_) if !path.exists() => Database::init_bare(path)?,
43            Err(_) if path.is_dir() && directory_is_empty(path)? => Database::init_bare(path)?,
44            Err(open_error) if path.is_dir() => {
45                return Err(Error::Invalid(format!(
46                "database path {} is a nonempty directory but not a Git repository: {open_error}",
47                path.display()
48            )))
49            }
50            Err(open_error) => return Err(open_error),
51        };
52        Ok(Self { database })
53    }
54
55    /// Returns a lazy handle for a named collection.
56    ///
57    /// This method performs no repository write. A missing collection is
58    /// created by its first nonempty [`CollectionHandle::upsert`].
59    pub fn collection(&self, name: impl Into<String>) -> CollectionHandle {
60        CollectionHandle {
61            database: self.database.clone(),
62            name: name.into(),
63        }
64    }
65
66    /// Returns collection names in canonical ascending order.
67    pub fn list_collections(&self) -> Result<Vec<String>> {
68        self.database.list_collections()
69    }
70
71    /// Returns the advanced named-database API.
72    pub fn advanced(&self) -> &Database {
73        &self.database
74    }
75}
76
77impl CollectionHandle {
78    /// Adds or replaces points, creating a missing collection on first write.
79    ///
80    /// The first write infers the collection dimension from its points. All
81    /// vectors in that batch must be nonempty and have the same dimension.
82    pub fn upsert(&self, points: impl IntoIterator<Item = Point>) -> Result<WriteResult> {
83        self.upsert_with_vector_space(points, None)
84    }
85
86    pub(crate) fn upsert_with_vector_space(
87        &self,
88        points: impl IntoIterator<Item = Point>,
89        vector_space: Option<&str>,
90    ) -> Result<WriteResult> {
91        let points: Vec<Point> = points.into_iter().collect();
92        let dimension = inferred_dimension(&points)?;
93
94        for _ in 0..WRITE_ATTEMPTS {
95            let collection = match self.database.collection(&self.name) {
96                Ok(collection) => collection,
97                Err(Error::CollectionNotFound(_)) => {
98                    let mut config = CollectionConfig::new(dimension);
99                    config.vector_space = vector_space.map(str::to_owned);
100                    match self.database.create_collection(&self.name, config) {
101                        Ok(collection) => collection,
102                        Err(Error::CollectionExists(_)) => {
103                            thread::yield_now();
104                            continue;
105                        }
106                        Err(Error::Git(error)) if retryable_ref_error(&error) => {
107                            thread::yield_now();
108                            continue;
109                        }
110                        Err(error) => return Err(error),
111                    }
112                }
113                Err(error) => return Err(error),
114            };
115
116            if let Some(vector_space) = vector_space {
117                let actual = collection.info()?.config.vector_space;
118                if actual.as_deref() != Some(vector_space) {
119                    return Err(Error::Invalid(format!(
120                        "collection {:?} uses vector space {:?}, expected {:?}",
121                        self.name, actual, vector_space
122                    )));
123                }
124            }
125
126            match collection.upsert(points.clone()) {
127                Ok(result) => return Ok(result),
128                Err(Error::StaleRoot { .. }) => thread::yield_now(),
129                Err(Error::Git(error)) if retryable_ref_error(&error) => thread::yield_now(),
130                Err(error) => return Err(error),
131            }
132        }
133
134        Err(Error::Invalid(format!(
135            "collection {:?} changed repeatedly while applying the first write; retry the upsert",
136            self.name
137        )))
138    }
139
140    /// Searches for the nearest points using the collection's automatic mode.
141    ///
142    /// Payload metadata is included in every returned winner. Use
143    /// [`CollectionHandle::advanced`] for filters, stored vectors, immutable
144    /// roots, execution statistics, or explicit search tuning.
145    pub fn search(
146        &self,
147        vector: impl IntoIterator<Item = f32>,
148        limit: usize,
149    ) -> Result<Vec<ScoredPoint>> {
150        Ok(self
151            .advanced()?
152            .query(Query::new(vector, limit).with_payload())?
153            .points)
154    }
155
156    /// Retrieves points by typed ID with payload metadata included.
157    pub fn get_ids(&self, ids: impl IntoIterator<Item = PointId>) -> Result<Vec<Record>> {
158        Ok(self
159            .advanced()?
160            .get(GetRequest {
161                ids: ids.into_iter().collect(),
162                with_payload: true,
163                ..GetRequest::default()
164            })?
165            .points)
166    }
167
168    /// Deletes points by typed ID. Missing IDs are ignored.
169    pub fn delete_ids(&self, ids: impl IntoIterator<Item = PointId>) -> Result<WriteResult> {
170        self.advanced()?.delete(DeleteSelector {
171            ids: ids.into_iter().collect(),
172            ..DeleteSelector::default()
173        })
174    }
175
176    /// Returns the number of points in the collection.
177    pub fn count(&self) -> Result<usize> {
178        Ok(self.advanced()?.count(None)?.count)
179    }
180
181    /// Returns the first `limit` canonically ordered points with payloads.
182    pub fn peek(&self, limit: usize) -> Result<Vec<Record>> {
183        Ok(self
184            .advanced()?
185            .get(GetRequest {
186                limit: Some(limit),
187                with_payload: true,
188                ..GetRequest::default()
189            })?
190            .points)
191    }
192
193    /// Opens the existing collection through the detailed named API.
194    pub fn advanced(&self) -> Result<Collection> {
195        self.database.collection(&self.name)
196    }
197}
198
199fn directory_is_empty(path: &Path) -> Result<bool> {
200    Ok(fs::read_dir(path)?.next().is_none())
201}
202
203fn inferred_dimension(points: &[Point]) -> Result<usize> {
204    let Some(first) = points.first() else {
205        return Err(Error::Invalid("upsert batch must not be empty".into()));
206    };
207    let dimension = first.vector.len();
208    if dimension == 0 {
209        return Err(Error::Invalid("point vector must not be empty".into()));
210    }
211    if let Some(point) = points.iter().find(|point| point.vector.len() != dimension) {
212        return Err(Error::Invalid(format!(
213            "point {} has dimension {}, expected {dimension}",
214            point.id,
215            point.vector.len()
216        )));
217    }
218    Ok(dimension)
219}
220
221fn retryable_ref_error(error: &git2::Error) -> bool {
222    matches!(
223        error.code(),
224        ErrorCode::Exists | ErrorCode::Locked | ErrorCode::Modified
225    )
226}