velesdb_core/database/collection_ops.rs
1//! Collection CRUD dispatcher: create, delete, list, get, and diagnostics.
2//!
3//! Type-specific operations are in sibling modules:
4//! - [`vector_ops`](super::vector_ops) — vector collection create/get
5//! - [`graph_ops`](super::graph_ops) — graph collection create/get
6//! - [`metadata_ops`](super::metadata_ops) — metadata-only collection create/get
7
8use crate::collection::AnyCollection;
9use crate::{CollectionType, DistanceMetric, Error, Result, StorageMode};
10
11use super::Database;
12
13impl Database {
14 /// Ensures a collection name is valid, free in memory, and free on disk.
15 ///
16 /// Validates the name against path traversal and forbidden characters
17 /// **before** any filesystem operation, then checks that no collection
18 /// with the same name already exists in any registry or on disk, and
19 /// finally enforces the `LimitsConfig::max_collections` cap so that
20 /// callers are refused cleanly instead of filling the registry past
21 /// the configured ceiling.
22 pub(super) fn ensure_collection_name_available(&self, name: &str) -> Result<()> {
23 crate::validation::validate_collection_name(name)?;
24
25 if self.collection_exists_in_registry(name) {
26 return Err(Error::CollectionExists(name.to_string()));
27 }
28
29 let collection_path = self.data_dir.join(name);
30 if collection_path.exists() {
31 return Err(Error::CollectionExists(name.to_string()));
32 }
33
34 // Wave 3 Commit 7 — enforce `LimitsConfig::max_collections`.
35 //
36 // Counted across every typed registry (vector + graph + metadata)
37 // because the limit is tenant-wide, not per-type. Evaluated after
38 // the name validation and duplicate checks so the typed error
39 // precedence stays unchanged: invalid name and duplicate still
40 // win over the cap — callers that want to detect "too many
41 // collections" specifically rely on the `GuardRail` variant.
42 let total_collections = self.vector_colls.read().len()
43 + self.graph_colls.read().len()
44 + self.metadata_colls.read().len();
45 let cap = self.config.limits.max_collections;
46 if total_collections >= cap {
47 return Err(Error::GuardRail(format!(
48 "max_collections limit reached ({total_collections} / {cap}); \
49 raise `limits.max_collections` in VelesConfig to create more"
50 )));
51 }
52
53 Ok(())
54 }
55
56 /// Pushes the live [`LimitsConfig`](crate::config::LimitsConfig) ingest/
57 /// search caps into a collection (parity item E).
58 ///
59 /// Single helper reused by the vector / graph / metadata registration and
60 /// disk-open paths so all three thread the same runtime limits into the
61 /// `Collection`. The limits are **not** persisted to `config.json`: they
62 /// are re-pushed on every open from the live `VelesConfig`.
63 pub(super) fn push_runtime_limits(&self, coll: &crate::collection::Collection) {
64 coll.set_runtime_limits(crate::collection::RuntimeLimits::from_config(
65 &self.config.limits,
66 ));
67 // `[search]` alongside `[limits]`, same contract: resolved here because
68 // `Database` is the only component holding a `VelesConfig`, pushed on
69 // every open, never persisted (#2087).
70 coll.set_runtime_search_quality(self.config.search.resolved_quality());
71 }
72
73 /// Checks whether a collection name exists in any of the typed registries.
74 fn collection_exists_in_registry(&self, name: &str) -> bool {
75 self.vector_colls.read().contains_key(name)
76 || self.graph_colls.read().contains_key(name)
77 || self.metadata_colls.read().contains_key(name)
78 }
79
80 /// Enforces `LimitsConfig::max_dimensions` on a prospective vector
81 /// collection creation.
82 ///
83 /// Complements [`crate::validation::validate_dimension`] (the static
84 /// `65_536` hard ceiling): the config-driven limit is typically tighter
85 /// — 4096 by default — and is consulted here so the guard-rail can
86 /// be relaxed per tenant via [`Database::open_with_config`] without
87 /// touching the static constant.
88 ///
89 /// Dimension `0` is accepted because it is the sentinel used by
90 /// metadata-only and graph-without-embeddings collections. Callers
91 /// that need to reject zero should do so upstream via
92 /// [`crate::validation::validate_dimension`].
93 pub(super) fn enforce_vector_dimension_limit(&self, dimension: usize) -> Result<()> {
94 if dimension == 0 {
95 return Ok(());
96 }
97 let cap = self.config.limits.max_dimensions;
98 if dimension > cap {
99 return Err(Error::GuardRail(format!(
100 "vector dimension {dimension} exceeds configured max_dimensions cap of {cap}; \
101 raise `limits.max_dimensions` in VelesConfig to allow larger vectors"
102 )));
103 }
104 Ok(())
105 }
106
107 /// Creates a new collection with the specified parameters.
108 ///
109 /// # Arguments
110 ///
111 /// * `name` - Unique name for the collection
112 /// * `dimension` - Vector dimension (e.g., 768 for many embedding models)
113 /// * `metric` - Distance metric to use for similarity calculations
114 ///
115 /// # Errors
116 ///
117 /// - Returns `Error::CollectionExists` if a collection with the same name already exists.
118 /// - Returns an error if the directory cannot be created or storage initialization fails.
119 ///
120 /// # Examples
121 ///
122 /// ```rust,no_run
123 /// # use velesdb_core::{Database, DistanceMetric};
124 /// let db = Database::open("./data")?;
125 /// db.create_collection("documents", 768, DistanceMetric::Cosine)?;
126 /// # Ok::<(), velesdb_core::Error>(())
127 /// ```
128 pub fn create_collection(
129 &self,
130 name: &str,
131 dimension: usize,
132 metric: DistanceMetric,
133 ) -> Result<()> {
134 self.create_collection_with_options(name, dimension, metric, StorageMode::default())
135 }
136
137 /// Creates a new collection with custom storage options.
138 ///
139 /// # Errors
140 ///
141 /// Returns an error if a collection with the same name already exists.
142 pub fn create_collection_with_options(
143 &self,
144 name: &str,
145 dimension: usize,
146 metric: DistanceMetric,
147 storage_mode: StorageMode,
148 ) -> Result<()> {
149 self.create_vector_collection_with_options(name, dimension, metric, storage_mode)
150 }
151
152 /// Returns a type-erased collection handle by name.
153 ///
154 /// Checks vector → graph → metadata registries in order.
155 /// Returns `None` if no collection with the given name exists.
156 #[must_use]
157 pub fn get_any_collection(&self, name: &str) -> Option<AnyCollection> {
158 if let Some(c) = self.get_vector_collection(name) {
159 return Some(AnyCollection::Vector(c));
160 }
161 if let Some(c) = self.get_graph_collection(name) {
162 return Some(AnyCollection::Graph(c));
163 }
164 if let Some(c) = self.get_metadata_collection(name) {
165 return Some(AnyCollection::Metadata(c));
166 }
167 None
168 }
169
170 /// Returns the write generation for a named collection, if it exists.
171 #[must_use]
172 pub fn collection_write_generation(&self, name: &str) -> Option<u64> {
173 if let Some(vc) = self.vector_colls.read().get(name) {
174 return Some(vc.inner.write_generation());
175 }
176 if let Some(gc) = self.graph_colls.read().get(name) {
177 return Some(gc.inner.write_generation());
178 }
179 if let Some(mc) = self.metadata_colls.read().get(name) {
180 return Some(mc.inner.write_generation());
181 }
182 None
183 }
184
185 /// Returns the set of payload field names covered by a secondary index
186 /// for the named collection (issue #607). Empty set when the collection
187 /// has no indexes or does not exist.
188 ///
189 /// Used by `Database::build_plan_with_stats` to thread the real
190 /// indexed-field set into `QueryPlan::from_query_with_stats` so that
191 /// `IndexLookup` plan nodes are generated in the EXPLAIN tree when a
192 /// WHERE clause targets an indexed column.
193 #[must_use]
194 pub fn indexed_fields_for(&self, name: &str) -> std::collections::HashSet<String> {
195 if let Some(vc) = self.vector_colls.read().get(name) {
196 return vc.inner.indexed_field_names();
197 }
198 if let Some(gc) = self.graph_colls.read().get(name) {
199 return gc.inner.indexed_field_names();
200 }
201 if let Some(mc) = self.metadata_colls.read().get(name) {
202 return mc.inner.indexed_field_names();
203 }
204 std::collections::HashSet::new()
205 }
206
207 /// Returns the live graph `CollectionStats` (node/edge counts, average
208 /// degree, label selectivity) for the named collection, used by the
209 /// `MatchQueryPlanner` to choose a traversal strategy in EXPLAIN of a
210 /// MATCH query (backlog #14). `None` when the collection does not exist.
211 #[must_use]
212 pub(crate) fn match_stats_for(
213 &self,
214 name: &str,
215 ) -> Option<crate::velesql::match_planner::MatchGraphStats> {
216 if let Some(vc) = self.vector_colls.read().get(name) {
217 return Some(vc.inner.compute_match_collection_stats());
218 }
219 if let Some(gc) = self.graph_colls.read().get(name) {
220 return Some(gc.inner.compute_match_collection_stats());
221 }
222 if let Some(mc) = self.metadata_colls.read().get(name) {
223 return Some(mc.inner.compute_match_collection_stats());
224 }
225 None
226 }
227
228 /// Returns the analyze generation for a named collection, if it exists
229 /// (issue #608).
230 ///
231 /// Parallel to [`Self::collection_write_generation`], but tracks `ANALYZE`
232 /// invocations instead of data mutations. Threaded into the compiled plan
233 /// cache key so that an `ANALYZE` run alone invalidates cached plans whose
234 /// cost estimates pre-date the fresh calibrated statistics.
235 #[must_use]
236 pub fn collection_analyze_generation(&self, name: &str) -> Option<u64> {
237 if let Some(vc) = self.vector_colls.read().get(name) {
238 return Some(vc.inner.analyze_generation());
239 }
240 if let Some(gc) = self.graph_colls.read().get(name) {
241 return Some(gc.inner.analyze_generation());
242 }
243 if let Some(mc) = self.metadata_colls.read().get(name) {
244 return Some(mc.inner.analyze_generation());
245 }
246 None
247 }
248
249 /// Renders every graph collection's metrics as a Prometheus exposition.
250 ///
251 /// One `GraphMetrics` exists per edge store, so the samples are tagged
252 /// with the collection they came from and each family is declared once;
253 /// see `collection::graph::graph_metrics_to_prometheus`. Returns an empty
254 /// string when the database holds no graph collection.
255 #[must_use]
256 pub fn graph_metrics_prometheus(&self) -> String {
257 let graph_colls = self.graph_colls.read();
258 let mut sorted: Vec<_> = graph_colls
259 .iter()
260 .map(|(name, coll)| (name.as_str(), coll.metrics()))
261 .collect();
262 // Stable output: a scrape diff should reflect metric movement, not
263 // HashMap iteration order.
264 sorted.sort_by_key(|(name, _)| *name);
265 let rendered = crate::collection::graph::graph_metrics_to_prometheus(&sorted);
266 // `sorted` borrows names out of the guard, so it goes first.
267 drop(sorted);
268 drop(graph_colls);
269 rendered
270 }
271
272 /// Renders the process-wide MATCH query metrics as a Prometheus
273 /// exposition (EPIC-050 US-002).
274 ///
275 /// Unlike graph metrics, MATCH metrics are not tagged per collection:
276 /// `MATCH` queries currently record into one global collector regardless
277 /// of which collection they touch (`match_metrics::global_match_metrics`).
278 #[must_use]
279 pub fn match_metrics_prometheus(&self) -> String {
280 crate::collection::search::query::match_metrics::global_match_metrics().to_prometheus()
281 }
282
283 /// Lists all collection names in the database.
284 ///
285 /// Includes collections created via any typed API (vector, graph, metadata).
286 pub fn list_collections(&self) -> Vec<String> {
287 let vector_colls = self.vector_colls.read();
288 let graph_colls = self.graph_colls.read();
289 let metadata_colls = self.metadata_colls.read();
290
291 let mut names: std::collections::HashSet<String> = vector_colls.keys().cloned().collect();
292 for k in graph_colls.keys() {
293 names.insert(k.clone());
294 }
295 for k in metadata_colls.keys() {
296 names.insert(k.clone());
297 }
298 // Every key is cloned, so the three maps are read as one consistent
299 // snapshot and then released together before the sort below.
300 drop(metadata_colls);
301 drop(graph_colls);
302 drop(vector_colls);
303 let mut result: Vec<String> = names.into_iter().collect();
304 result.sort();
305 result
306 }
307
308 /// Deletes a collection by name.
309 ///
310 /// # Errors
311 ///
312 /// Returns an error if the name is invalid or the collection does not
313 /// exist in any registry.
314 pub fn delete_collection(&self, name: &str) -> Result<()> {
315 crate::validation::validate_collection_name(name)?;
316
317 if !self.collection_exists_in_registry(name) {
318 return Err(Error::CollectionNotFound(name.to_string()));
319 }
320
321 let collection_path = self.data_dir.join(name);
322 if collection_path.exists() {
323 std::fs::remove_dir_all(&collection_path)?;
324 }
325
326 self.remove_from_all_registries(name);
327
328 if let Some(ref obs) = self.observer {
329 obs.on_collection_deleted(name);
330 }
331
332 self.schema_version
333 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
334
335 Ok(())
336 }
337
338 /// Removes a collection from all registries and stats cache.
339 fn remove_from_all_registries(&self, name: &str) {
340 self.vector_colls.write().remove(name);
341 self.graph_colls.write().remove(name);
342 self.metadata_colls.write().remove(name);
343 self.collection_stats.write().remove(name);
344 self.join_store_cache.write().remove(name);
345 }
346
347 /// Creates a new collection with a specific type (Vector, Graph, or `MetadataOnly`).
348 ///
349 /// # Errors
350 ///
351 /// Returns an error if a collection with the same name already exists.
352 pub fn create_collection_typed(
353 &self,
354 name: &str,
355 collection_type: &CollectionType,
356 ) -> Result<()> {
357 match collection_type {
358 CollectionType::Vector {
359 dimension,
360 metric,
361 storage_mode,
362 } => {
363 self.create_vector_collection_with_options(name, *dimension, *metric, *storage_mode)
364 }
365 CollectionType::MetadataOnly => self.create_metadata_collection(name),
366 CollectionType::Graph {
367 dimension,
368 metric,
369 schema,
370 } => self.create_graph_collection_from_type(name, *dimension, *metric, schema),
371 }
372 }
373
374 /// Reads and parses `config.json` from a collection directory.
375 ///
376 /// Returns `None` if the name is invalid, the config file does not exist,
377 /// or the config cannot be parsed.
378 pub(super) fn read_collection_config(
379 &self,
380 name: &str,
381 ) -> Option<crate::collection::CollectionConfig> {
382 if crate::validation::validate_collection_name(name).is_err() {
383 return None;
384 }
385 let path = self.data_dir.join(name);
386 let config_path = path.join("config.json");
387 if !config_path.exists() {
388 return None;
389 }
390 let data = std::fs::read_to_string(&config_path).ok()?;
391 serde_json::from_str(&data).ok()
392 }
393
394 /// Propagates updated query limits to all active collections.
395 pub fn update_guardrails(&self, limits: &crate::guardrails::QueryLimits) {
396 for vc in self.vector_colls.read().values() {
397 vc.guard_rails().update_limits(limits);
398 }
399 for gc in self.graph_colls.read().values() {
400 gc.inner.guard_rails().update_limits(limits);
401 }
402 for mc in self.metadata_colls.read().values() {
403 mc.inner.guard_rails().update_limits(limits);
404 }
405 }
406
407 /// Returns diagnostics for a named collection.
408 ///
409 /// # Errors
410 ///
411 /// Returns `Error::CollectionNotFound` if the collection does not exist.
412 pub fn collection_diagnostics(
413 &self,
414 name: &str,
415 ) -> Result<crate::collection::CollectionDiagnostics> {
416 if let Some(c) = self.get_vector_collection(name) {
417 return Ok(c.diagnostics());
418 }
419 if let Some(c) = self.get_graph_collection(name) {
420 return Ok(c.diagnostics());
421 }
422 if let Some(c) = self.get_metadata_collection(name) {
423 return Ok(c.diagnostics());
424 }
425 Err(Error::CollectionNotFound(name.to_string()))
426 }
427}