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