Skip to main content

vectorizer_sdk/client/
collections.rs

1//! Collection-management surface: list, create, get info, delete.
2//!
3//! These are the four endpoints that operate on collections as a
4//! whole — vector-level CRUD lives in [`super::vectors`], search
5//! over a collection lives in [`super::search`].
6
7use super::VectorizerClient;
8use crate::error::{Result, VectorizerError};
9use crate::models::*;
10
11impl VectorizerClient {
12    /// List every collection visible to the authenticated principal.
13    /// Accepts both the legacy bare-array response and the newer
14    /// `{collections: [...]}` wrapper.
15    pub async fn list_collections(&self) -> Result<Vec<Collection>> {
16        let response = self.make_request("GET", "/collections", None).await?;
17        let collections: Vec<Collection> = if let Ok(wrapper) =
18            serde_json::from_str::<serde_json::Value>(&response)
19        {
20            if let Some(arr) = wrapper.get("collections").and_then(|v| v.as_array()) {
21                serde_json::from_value(serde_json::Value::Array(arr.clone())).map_err(|e| {
22                    VectorizerError::server(format!("Failed to parse collections array: {e}"))
23                })?
24            } else if wrapper.is_array() {
25                serde_json::from_value(wrapper).map_err(|e| {
26                    VectorizerError::server(format!("Failed to parse collections response: {e}"))
27                })?
28            } else {
29                return Err(VectorizerError::server(
30                    "Unexpected collections response format".to_string(),
31                ));
32            }
33        } else {
34            return Err(VectorizerError::server(
35                "Failed to parse collections response".to_string(),
36            ));
37        };
38        Ok(collections)
39    }
40
41    /// Create a new collection. The returned [`CollectionInfo`] is
42    /// synthesised from the server's create-response plus the
43    /// arguments — the server response only carries the collection
44    /// name today.
45    pub async fn create_collection(
46        &self,
47        name: &str,
48        dimension: usize,
49        metric: Option<SimilarityMetric>,
50    ) -> Result<CollectionInfo> {
51        let mut payload = serde_json::Map::new();
52        payload.insert(
53            "name".to_string(),
54            serde_json::Value::String(name.to_string()),
55        );
56        payload.insert(
57            "dimension".to_string(),
58            serde_json::Value::Number(dimension.into()),
59        );
60        payload.insert(
61            "metric".to_string(),
62            serde_json::Value::String(format!("{:?}", metric.unwrap_or_default()).to_lowercase()),
63        );
64
65        let response = self
66            .make_request(
67                "POST",
68                "/collections",
69                Some(serde_json::Value::Object(payload)),
70            )
71            .await?;
72        let create_response: CreateCollectionResponse =
73            serde_json::from_str(&response).map_err(|e| {
74                VectorizerError::server(format!("Failed to parse create collection response: {e}"))
75            })?;
76
77        let info = CollectionInfo {
78            name: create_response.collection,
79            dimension,
80            metric: format!("{:?}", metric.unwrap_or_default()).to_lowercase(),
81            vector_count: 0,
82            document_count: 0,
83            created_at: String::new(),
84            updated_at: String::new(),
85            indexing_status: Some(crate::models::IndexingStatus {
86                status: "created".to_string(),
87                progress: 0.0,
88                total_documents: 0,
89                processed_documents: 0,
90                vector_count: 0,
91                estimated_time_remaining: None,
92                last_updated: String::new(),
93            }),
94            size: None,
95            quantization: None,
96            normalization: None,
97            status: Some("created".to_string()),
98        };
99        Ok(info)
100    }
101
102    /// Delete a collection by name.
103    pub async fn delete_collection(&self, name: &str) -> Result<()> {
104        self.make_request("DELETE", &format!("/collections/{name}"), None)
105            .await?;
106        Ok(())
107    }
108
109    /// Fetch metadata for a collection (vector count, dimension,
110    /// metric, timestamps, indexing status).
111    pub async fn get_collection_info(&self, collection: &str) -> Result<CollectionInfo> {
112        let response = self
113            .make_request("GET", &format!("/collections/{collection}"), None)
114            .await?;
115        let info: CollectionInfo = serde_json::from_str(&response).map_err(|e| {
116            VectorizerError::server(format!("Failed to parse collection info: {e}"))
117        })?;
118        Ok(info)
119    }
120
121    /// Re-quantize an existing collection in-place without re-embedding
122    /// (phase13).
123    ///
124    /// Calls `POST /collections/{name}/reencode` with
125    /// `{"target_encoding": "<encoding>"}`. Valid encoding values:
126    /// `"sq8"`, `"binary"`, `"fp32"`.
127    ///
128    /// The server runs the reencode synchronously and returns
129    /// `{job_id, collection, state, target_encoding, progress}` on
130    /// completion. `state` will be `"completed"` on success.
131    pub async fn reencode_collection(
132        &self,
133        collection: &str,
134        target_encoding: &str,
135    ) -> Result<ReencodeJob> {
136        let payload = serde_json::json!({ "target_encoding": target_encoding });
137        let response = self
138            .make_request(
139                "POST",
140                &format!("/collections/{collection}/reencode"),
141                Some(payload),
142            )
143            .await?;
144        serde_json::from_str(&response).map_err(|e| {
145            VectorizerError::server(format!("Failed to parse reencode_collection response: {e}"))
146        })
147    }
148
149    /// Set or clear a per-collection TTL (phase13).
150    ///
151    /// Calls `POST /collections/{name}/ttl` with `{"ttl_secs": <secs>}`.
152    /// Pass `None` to clear the collection-level TTL. Existing vectors are
153    /// NOT retroactively expired; only subsequent insertions that carry
154    /// `__expires_at` in their payload are affected.
155    ///
156    /// For per-vector expiry use `set_vector_expiry` on the vectors surface.
157    pub async fn set_collection_ttl(&self, collection: &str, ttl_secs: Option<u64>) -> Result<()> {
158        let payload = serde_json::json!({ "ttl_secs": ttl_secs });
159        self.make_request(
160            "POST",
161            &format!("/collections/{collection}/ttl"),
162            Some(payload),
163        )
164        .await?;
165        Ok(())
166    }
167
168    // ── Phase-14: schema-evolution methods ────────────────────────────────────
169
170    /// Atomically rename a collection (phase14).
171    ///
172    /// Calls `POST /collections/{name}/rename` with `{"new_name": "<name>"}`.
173    ///
174    /// The server keeps the old name as an in-memory alias for one minor
175    /// version so existing clients keep working without reconfiguration.
176    /// The alias does not survive a restart.
177    pub async fn rename_collection(&self, collection: &str, new_name: &str) -> Result<()> {
178        let payload = serde_json::json!({ "new_name": new_name });
179        self.make_request(
180            "POST",
181            &format!("/collections/{collection}/rename"),
182            Some(payload),
183        )
184        .await?;
185        Ok(())
186    }
187
188    /// Rebuild the HNSW index with new parameters (phase14).
189    ///
190    /// Calls `POST /collections/{name}/reindex` with
191    /// `{"m": u32, "ef_construction": u32, "ef_search": u32}`.
192    ///
193    /// No re-embedding is required — the existing stored vectors are used.
194    /// The server holds the collection write-lock for the duration, so
195    /// concurrent inserts queue behind the swap.
196    ///
197    /// Returns a [`ReindexJob`] with `state == "completed"` on success.
198    pub async fn reindex_collection(
199        &self,
200        collection: &str,
201        params: crate::models::ReindexParams,
202    ) -> Result<crate::models::ReindexJob> {
203        let payload = serde_json::json!({
204            "m": params.m,
205            "ef_construction": params.ef_construction,
206            "ef_search": params.ef_search,
207        });
208        let response = self
209            .make_request(
210                "POST",
211                &format!("/collections/{collection}/reindex"),
212                Some(payload),
213            )
214            .await?;
215        serde_json::from_str(&response).map_err(|e| {
216            VectorizerError::server(format!("Failed to parse reindex_collection response: {e}"))
217        })
218    }
219
220    /// Create a native per-collection snapshot (phase14).
221    ///
222    /// Calls `POST /collections/{name}/snapshot` (empty body).
223    ///
224    /// The server writes a gzip-compressed JSON snapshot under
225    /// `<data_dir>/collection_snapshots/<name>/` and returns the snapshot
226    /// metadata.
227    pub async fn snapshot_collection_native(
228        &self,
229        collection: &str,
230    ) -> Result<crate::models::NativeSnapshotInfo> {
231        let response = self
232            .make_request(
233                "POST",
234                &format!("/collections/{collection}/snapshot"),
235                Some(serde_json::json!({})),
236            )
237            .await?;
238        serde_json::from_str(&response).map_err(|e| {
239            VectorizerError::server(format!(
240                "Failed to parse snapshot_collection_native response: {e}"
241            ))
242        })
243    }
244
245    /// List all native snapshots for a collection (phase14).
246    ///
247    /// Calls `GET /collections/{name}/snapshots`.
248    ///
249    /// Returns snapshots newest-first as reported by the server.
250    pub async fn list_collection_snapshots_native(
251        &self,
252        collection: &str,
253    ) -> Result<Vec<crate::models::NativeSnapshotInfo>> {
254        let response = self
255            .make_request("GET", &format!("/collections/{collection}/snapshots"), None)
256            .await?;
257        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
258            VectorizerError::server(format!(
259                "Failed to parse list_collection_snapshots_native response: {e}"
260            ))
261        })?;
262        let arr = val
263            .get("snapshots")
264            .and_then(|s| s.as_array())
265            .cloned()
266            .unwrap_or_default();
267        arr.into_iter()
268            .map(|v| {
269                serde_json::from_value(v).map_err(|e| {
270                    VectorizerError::server(format!("Failed to parse snapshot entry: {e}"))
271                })
272            })
273            .collect()
274    }
275
276    /// Restore a collection from a native snapshot (phase14).
277    ///
278    /// Calls `POST /collections/{name}/snapshots/{id}/restore` (empty body).
279    ///
280    /// Drops the current in-memory state and replaces it with the snapshot data.
281    pub async fn restore_collection_snapshot_native(
282        &self,
283        collection: &str,
284        snapshot_id: &str,
285    ) -> Result<()> {
286        self.make_request(
287            "POST",
288            &format!("/collections/{collection}/snapshots/{snapshot_id}/restore"),
289            Some(serde_json::json!({})),
290        )
291        .await?;
292        Ok(())
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use serde_json::json;
299
300    use crate::models::{NativeSnapshotInfo, ReencodeJob, ReindexJob, ReindexParams};
301
302    #[test]
303    fn reencode_job_wire_shape() {
304        // Mirror of `POST /collections/{name}/reencode` response.
305        let raw = json!({
306            "job_id": "reencode-myc-1746000000",
307            "collection": "myc",
308            "state": "completed",
309            "target_encoding": "fp32",
310            "progress": 1.0,
311        });
312        let job: ReencodeJob = serde_json::from_value(raw).unwrap();
313        assert_eq!(job.job_id, "reencode-myc-1746000000");
314        assert_eq!(job.state, "completed");
315        assert_eq!(job.target_encoding, "fp32");
316    }
317
318    #[test]
319    fn set_collection_ttl_payload_shape() {
320        // Verify the JSON payload serializes correctly for both Some and None.
321        let with_ttl = json!({ "ttl_secs": 3600u64 });
322        assert_eq!(with_ttl["ttl_secs"], 3600);
323
324        let clear_ttl = json!({ "ttl_secs": serde_json::Value::Null });
325        assert!(clear_ttl["ttl_secs"].is_null());
326    }
327
328    // ── Phase-14 round-trip tests ─────────────────────────────────────────────
329
330    #[test]
331    fn rename_collection_payload_shape() {
332        // Verify `POST /collections/{name}/rename` body serializes correctly.
333        let payload = json!({ "new_name": "docs_v2" });
334        assert_eq!(payload["new_name"], "docs_v2");
335    }
336
337    #[test]
338    fn reindex_params_serialize() {
339        let params = ReindexParams {
340            m: 32,
341            ef_construction: 400,
342            ef_search: 200,
343        };
344        let v = serde_json::to_value(&params).unwrap();
345        assert_eq!(v["m"], 32);
346        assert_eq!(v["ef_construction"], 400);
347        assert_eq!(v["ef_search"], 200);
348    }
349
350    #[test]
351    fn reindex_job_wire_shape() {
352        // Mirror of `POST /collections/{name}/reindex` response.
353        let raw = json!({
354            "job_id": "reindex-docs-1746000001",
355            "collection": "docs",
356            "state": "completed",
357            "params": { "m": 32, "ef_construction": 400, "ef_search": 200 },
358            "progress": 1.0,
359        });
360        let job: ReindexJob = serde_json::from_value(raw).unwrap();
361        assert_eq!(job.job_id, "reindex-docs-1746000001");
362        assert_eq!(job.state, "completed");
363        assert!((job.progress - 1.0).abs() < f64::EPSILON);
364    }
365
366    #[test]
367    fn native_snapshot_info_wire_shape() {
368        // Mirror of `POST /collections/{name}/snapshot` response.
369        let raw = json!({
370            "id": "snap-abc-123",
371            "collection": "docs",
372            "created_at": "2026-05-02T00:00:00Z",
373            "size_bytes": 4096u64,
374            "status": "ok",
375        });
376        let info: NativeSnapshotInfo = serde_json::from_value(raw).unwrap();
377        assert_eq!(info.id, "snap-abc-123");
378        assert_eq!(info.collection, "docs");
379        assert_eq!(info.size_bytes, 4096);
380    }
381
382    #[test]
383    fn list_snapshots_response_parses() {
384        // Mirror of `GET /collections/{name}/snapshots` response.
385        let raw = json!({
386            "collection": "docs",
387            "snapshots": [
388                {
389                    "id": "snap-abc-123",
390                    "collection": "docs",
391                    "created_at": "2026-05-02T00:00:00Z",
392                    "size_bytes": 4096u64,
393                }
394            ],
395            "total": 1,
396        });
397        let arr = raw["snapshots"].as_array().unwrap();
398        let snaps: Vec<NativeSnapshotInfo> = arr
399            .iter()
400            .map(|v| serde_json::from_value(v.clone()).unwrap())
401            .collect();
402        assert_eq!(snaps.len(), 1);
403        assert_eq!(snaps[0].id, "snap-abc-123");
404    }
405
406    #[test]
407    fn restore_snapshot_payload_shape() {
408        // `POST /collections/{name}/snapshots/{id}/restore` sends empty body.
409        let payload = json!({});
410        assert!(payload.as_object().map(|o| o.is_empty()).unwrap_or(false));
411    }
412}