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; a value below 1 is
153    /// rejected by the server.
154    ///
155    /// Vectors inserted or updated after the call carry
156    /// `__expires_at = now + ttl_secs` and are deleted by the server's TTL
157    /// reaper once that timestamp passes. Existing vectors are NOT
158    /// retroactively expired, and a vector that already carries its own
159    /// `__expires_at` keeps it.
160    ///
161    /// The rule is durable: the server stores it with the collection and
162    /// restores it on load, so it still applies after a restart.
163    ///
164    /// For per-vector expiry use `set_vector_expiry` on the vectors surface.
165    pub async fn set_collection_ttl(&self, collection: &str, ttl_secs: Option<u64>) -> Result<()> {
166        let payload = serde_json::json!({ "ttl_secs": ttl_secs });
167        self.make_request(
168            "POST",
169            &format!("/collections/{collection}/ttl"),
170            Some(payload),
171        )
172        .await?;
173        Ok(())
174    }
175
176    /// Read the per-collection TTL in seconds, or `None` when no TTL is
177    /// configured.
178    ///
179    /// Calls `GET /collections/{name}/ttl`.
180    pub async fn get_collection_ttl(&self, collection: &str) -> Result<Option<u64>> {
181        let response = self
182            .make_request("GET", &format!("/collections/{collection}/ttl"), None)
183            .await?;
184        let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
185            VectorizerError::server(format!("Failed to parse get_collection_ttl response: {e}"))
186        })?;
187        Ok(parsed.get("ttl_secs").and_then(serde_json::Value::as_u64))
188    }
189
190    // ── Phase-14: schema-evolution methods ────────────────────────────────────
191
192    /// Atomically rename a collection (phase14).
193    ///
194    /// Calls `POST /collections/{name}/rename` with `{"new_name": "<name>"}`.
195    ///
196    /// The server keeps the old name as an in-memory alias for one minor
197    /// version so existing clients keep working without reconfiguration.
198    /// The alias does not survive a restart.
199    pub async fn rename_collection(&self, collection: &str, new_name: &str) -> Result<()> {
200        let payload = serde_json::json!({ "new_name": new_name });
201        self.make_request(
202            "POST",
203            &format!("/collections/{collection}/rename"),
204            Some(payload),
205        )
206        .await?;
207        Ok(())
208    }
209
210    /// Rebuild the HNSW index with new parameters (phase14).
211    ///
212    /// Calls `POST /collections/{name}/reindex` with
213    /// `{"m": u32, "ef_construction": u32, "ef_search": u32}`.
214    ///
215    /// No re-embedding is required — the existing stored vectors are used.
216    /// The server holds the collection write-lock for the duration, so
217    /// concurrent inserts queue behind the swap.
218    ///
219    /// Returns a [`ReindexJob`] with `state == "completed"` on success.
220    pub async fn reindex_collection(
221        &self,
222        collection: &str,
223        params: crate::models::ReindexParams,
224    ) -> Result<crate::models::ReindexJob> {
225        let payload = serde_json::json!({
226            "m": params.m,
227            "ef_construction": params.ef_construction,
228            "ef_search": params.ef_search,
229        });
230        let response = self
231            .make_request(
232                "POST",
233                &format!("/collections/{collection}/reindex"),
234                Some(payload),
235            )
236            .await?;
237        serde_json::from_str(&response).map_err(|e| {
238            VectorizerError::server(format!("Failed to parse reindex_collection response: {e}"))
239        })
240    }
241
242    /// Create a native per-collection snapshot (phase14).
243    ///
244    /// Calls `POST /collections/{name}/snapshot` (empty body).
245    ///
246    /// The server writes a gzip-compressed JSON snapshot under
247    /// `<data_dir>/collection_snapshots/<name>/` and returns the snapshot
248    /// metadata.
249    pub async fn snapshot_collection_native(
250        &self,
251        collection: &str,
252    ) -> Result<crate::models::NativeSnapshotInfo> {
253        let response = self
254            .make_request(
255                "POST",
256                &format!("/collections/{collection}/snapshot"),
257                Some(serde_json::json!({})),
258            )
259            .await?;
260        serde_json::from_str(&response).map_err(|e| {
261            VectorizerError::server(format!(
262                "Failed to parse snapshot_collection_native response: {e}"
263            ))
264        })
265    }
266
267    /// List all native snapshots for a collection (phase14).
268    ///
269    /// Calls `GET /collections/{name}/snapshots`.
270    ///
271    /// Returns snapshots newest-first as reported by the server.
272    pub async fn list_collection_snapshots_native(
273        &self,
274        collection: &str,
275    ) -> Result<Vec<crate::models::NativeSnapshotInfo>> {
276        let response = self
277            .make_request("GET", &format!("/collections/{collection}/snapshots"), None)
278            .await?;
279        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
280            VectorizerError::server(format!(
281                "Failed to parse list_collection_snapshots_native response: {e}"
282            ))
283        })?;
284        let arr = val
285            .get("snapshots")
286            .and_then(|s| s.as_array())
287            .cloned()
288            .unwrap_or_default();
289        arr.into_iter()
290            .map(|v| {
291                serde_json::from_value(v).map_err(|e| {
292                    VectorizerError::server(format!("Failed to parse snapshot entry: {e}"))
293                })
294            })
295            .collect()
296    }
297
298    /// Restore a collection from a native snapshot (phase14).
299    ///
300    /// Calls `POST /collections/{name}/snapshots/{id}/restore` (empty body).
301    ///
302    /// Drops the current in-memory state and replaces it with the snapshot data.
303    pub async fn restore_collection_snapshot_native(
304        &self,
305        collection: &str,
306        snapshot_id: &str,
307    ) -> Result<()> {
308        self.make_request(
309            "POST",
310            &format!("/collections/{collection}/snapshots/{snapshot_id}/restore"),
311            Some(serde_json::json!({})),
312        )
313        .await?;
314        Ok(())
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use serde_json::json;
321
322    use crate::models::{NativeSnapshotInfo, ReencodeJob, ReindexJob, ReindexParams};
323
324    #[test]
325    fn reencode_job_wire_shape() {
326        // Mirror of `POST /collections/{name}/reencode` response.
327        let raw = json!({
328            "job_id": "reencode-myc-1746000000",
329            "collection": "myc",
330            "state": "completed",
331            "target_encoding": "fp32",
332            "progress": 1.0,
333        });
334        let job: ReencodeJob = serde_json::from_value(raw).unwrap();
335        assert_eq!(job.job_id, "reencode-myc-1746000000");
336        assert_eq!(job.state, "completed");
337        assert_eq!(job.target_encoding, "fp32");
338    }
339
340    #[test]
341    fn set_collection_ttl_payload_shape() {
342        // Verify the JSON payload serializes correctly for both Some and None.
343        let with_ttl = json!({ "ttl_secs": 3600u64 });
344        assert_eq!(with_ttl["ttl_secs"], 3600);
345
346        let clear_ttl = json!({ "ttl_secs": serde_json::Value::Null });
347        assert!(clear_ttl["ttl_secs"].is_null());
348    }
349
350    #[test]
351    fn get_collection_ttl_reads_both_wire_shapes() {
352        // Mirror of `GET /collections/{name}/ttl` for configured and cleared.
353        let configured = json!({"collection": "myc", "ttl_secs": 900u64});
354        assert_eq!(
355            configured
356                .get("ttl_secs")
357                .and_then(serde_json::Value::as_u64),
358            Some(900)
359        );
360
361        let cleared = json!({"collection": "myc", "ttl_secs": serde_json::Value::Null});
362        assert_eq!(
363            cleared.get("ttl_secs").and_then(serde_json::Value::as_u64),
364            None
365        );
366    }
367
368    // ── Phase-14 round-trip tests ─────────────────────────────────────────────
369
370    #[test]
371    fn rename_collection_payload_shape() {
372        // Verify `POST /collections/{name}/rename` body serializes correctly.
373        let payload = json!({ "new_name": "docs_v2" });
374        assert_eq!(payload["new_name"], "docs_v2");
375    }
376
377    #[test]
378    fn reindex_params_serialize() {
379        let params = ReindexParams {
380            m: 32,
381            ef_construction: 400,
382            ef_search: 200,
383        };
384        let v = serde_json::to_value(&params).unwrap();
385        assert_eq!(v["m"], 32);
386        assert_eq!(v["ef_construction"], 400);
387        assert_eq!(v["ef_search"], 200);
388    }
389
390    #[test]
391    fn reindex_job_wire_shape() {
392        // Mirror of `POST /collections/{name}/reindex` response.
393        let raw = json!({
394            "job_id": "reindex-docs-1746000001",
395            "collection": "docs",
396            "state": "completed",
397            "params": { "m": 32, "ef_construction": 400, "ef_search": 200 },
398            "progress": 1.0,
399        });
400        let job: ReindexJob = serde_json::from_value(raw).unwrap();
401        assert_eq!(job.job_id, "reindex-docs-1746000001");
402        assert_eq!(job.state, "completed");
403        assert!((job.progress - 1.0).abs() < f64::EPSILON);
404    }
405
406    #[test]
407    fn native_snapshot_info_wire_shape() {
408        // Mirror of `POST /collections/{name}/snapshot` response.
409        let raw = json!({
410            "id": "snap-abc-123",
411            "collection": "docs",
412            "created_at": "2026-05-02T00:00:00Z",
413            "size_bytes": 4096u64,
414            "status": "ok",
415        });
416        let info: NativeSnapshotInfo = serde_json::from_value(raw).unwrap();
417        assert_eq!(info.id, "snap-abc-123");
418        assert_eq!(info.collection, "docs");
419        assert_eq!(info.size_bytes, 4096);
420    }
421
422    #[test]
423    fn list_snapshots_response_parses() {
424        // Mirror of `GET /collections/{name}/snapshots` response.
425        let raw = json!({
426            "collection": "docs",
427            "snapshots": [
428                {
429                    "id": "snap-abc-123",
430                    "collection": "docs",
431                    "created_at": "2026-05-02T00:00:00Z",
432                    "size_bytes": 4096u64,
433                }
434            ],
435            "total": 1,
436        });
437        let arr = raw["snapshots"].as_array().unwrap();
438        let snaps: Vec<NativeSnapshotInfo> = arr
439            .iter()
440            .map(|v| serde_json::from_value(v.clone()).unwrap())
441            .collect();
442        assert_eq!(snaps.len(), 1);
443        assert_eq!(snaps[0].id, "snap-abc-123");
444    }
445
446    #[test]
447    fn restore_snapshot_payload_shape() {
448        // `POST /collections/{name}/snapshots/{id}/restore` sends empty body.
449        let payload = json!({});
450        assert!(payload.as_object().map(|o| o.is_empty()).unwrap_or(false));
451    }
452}