1use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::future::Future;
6
7#[derive(Debug, thiserror::Error)]
12pub enum ContextError {
13 #[error("{0}")]
14 NotFound(String),
15 #[error("{0}")]
16 AlreadyExists(String),
17 #[error("{0}")]
18 InvalidRequest(String),
19 #[error("{0}")]
20 Internal(String),
21 #[error("Compaction already in progress")]
22 CompactionInProgress,
23}
24
25pub type ContextResult<T> = Result<T, ContextError>;
26
27pub trait ContextStoreApi {
32 fn add(
33 &mut self,
34 records: &[AddRecordRequest],
35 ) -> impl Future<Output = ContextResult<AddRecordsResponse>> + Send;
36
37 fn upsert(
38 &mut self,
39 request: &UpsertRecordRequest,
40 ) -> impl Future<Output = ContextResult<UpsertRecordResponse>> + Send;
41
42 fn upsert_many(
43 &mut self,
44 request: &UpsertRecordsRequest,
45 ) -> impl Future<Output = ContextResult<UpsertRecordsResponse>> + Send;
46
47 fn update(
48 &mut self,
49 request: &UpdateRecordRequest,
50 ) -> impl Future<Output = ContextResult<UpdateRecordResponse>> + Send;
51
52 fn get(&self, id: &str) -> impl Future<Output = ContextResult<Option<RecordDto>>> + Send;
53
54 fn get_by_external_id(
55 &self,
56 external_id: &str,
57 ) -> impl Future<Output = ContextResult<Option<RecordDto>>> + Send;
58
59 fn delete_by_id(
60 &mut self,
61 id: &str,
62 ) -> impl Future<Output = ContextResult<DeleteRecordResponse>> + Send;
63
64 fn delete_by_external_id(
65 &mut self,
66 external_id: &str,
67 ) -> impl Future<Output = ContextResult<DeleteRecordResponse>> + Send;
68
69 fn list(
70 &self,
71 limit: Option<usize>,
72 offset: Option<usize>,
73 filters: Option<Value>,
74 include_expired: bool,
75 include_retired: bool,
76 ) -> impl Future<Output = ContextResult<Vec<RecordDto>>> + Send;
77
78 fn related(
79 &self,
80 target_id: &str,
81 relation: Option<&str>,
82 limit: Option<usize>,
83 include_expired: bool,
84 include_retired: bool,
85 ) -> impl Future<Output = ContextResult<Vec<RecordDto>>> + Send;
86
87 fn search(
88 &self,
89 request: &SearchRequest,
90 ) -> impl Future<Output = ContextResult<Vec<SearchResultDto>>> + Send;
91
92 fn retrieve(
93 &self,
94 request: &RetrieveRequest,
95 ) -> impl Future<Output = ContextResult<Vec<RetrieveResultDto>>> + Send;
96
97 fn version(&self) -> u64;
98
99 fn checkout(&mut self, version: u64) -> impl Future<Output = ContextResult<()>> + Send;
100
101 fn compact(
102 &mut self,
103 options: Option<CompactRequest>,
104 ) -> impl Future<Output = ContextResult<CompactResponse>> + Send;
105
106 fn compaction_stats(&self) -> impl Future<Output = ContextResult<CompactStatsResponse>> + Send;
107}
108
109#[derive(Debug, Serialize, Deserialize)]
114pub struct CreateContextRequest {
115 pub name: String,
116 #[serde(default)]
117 pub storage_options: Option<std::collections::HashMap<String, String>>,
118 #[serde(default)]
119 pub id_index_type: Option<String>,
120 #[serde(default)]
121 pub blob_columns: Option<Vec<String>>,
122 #[serde(default)]
123 pub embedding_dim: Option<i32>,
124 #[serde(default)]
125 pub distance_metric: Option<String>,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129pub struct ContextInfo {
130 pub name: String,
131 pub uri: String,
132 pub version: u64,
133}
134
135#[derive(Debug, Serialize, Deserialize)]
136pub struct ListContextsResponse {
137 pub contexts: Vec<ContextInfo>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct StateMetadataDto {
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub step: Option<i32>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub active_plan_id: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub tokens_used: Option<i32>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub custom: Option<String>,
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct RelationshipDto {
158 pub target_id: String,
159 pub relation: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub weight: Option<f32>,
162}
163
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct AddRecordRequest {
166 #[serde(default = "default_role")]
167 pub role: String,
168 #[serde(default = "default_content_type")]
169 pub content_type: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub text_payload: Option<String>,
172 #[serde(
173 default,
174 skip_serializing_if = "Option::is_none",
175 serialize_with = "serialize_base64_opt",
176 deserialize_with = "deserialize_base64_opt"
177 )]
178 pub binary_payload: Option<Vec<u8>>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub payload_uri: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub payload_size: Option<i64>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub payload_checksum: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub embedding: Option<Vec<f32>>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub bot_id: Option<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub session_id: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub tenant: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub source: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub external_id: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub state_metadata: Option<StateMetadataDto>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub metadata: Option<Value>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub relationships: Vec<RelationshipDto>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub expires_at: Option<DateTime<Utc>>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub retention_policy: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub supersedes_id: Option<String>,
211}
212
213#[derive(Debug, Serialize, Deserialize)]
214pub struct AddRecordsRequest {
215 pub records: Vec<AddRecordRequest>,
216}
217
218#[derive(Debug, Serialize, Deserialize)]
219pub struct AddRecordsResponse {
220 pub version: u64,
221 pub ids: Vec<String>,
222 pub count: usize,
223}
224
225#[derive(Debug, Serialize, Deserialize)]
226pub struct UpsertRecordRequest {
227 pub record: AddRecordRequest,
228 #[serde(default = "default_upsert_key")]
229 pub key: String,
230}
231
232#[derive(Debug, Serialize, Deserialize)]
233pub struct UpsertRecordResponse {
234 pub version: u64,
235 pub inserted: bool,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub replaced_id: Option<String>,
238 pub record: RecordDto,
239}
240
241#[derive(Debug, Serialize, Deserialize)]
242pub struct UpsertRecordsRequest {
243 pub records: Vec<AddRecordRequest>,
244 #[serde(default = "default_upsert_key")]
245 pub key: String,
246}
247
248#[derive(Debug, Serialize, Deserialize)]
250pub struct UpsertResultDto {
251 pub inserted: bool,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub replaced_id: Option<String>,
254 pub record: RecordDto,
255}
256
257#[derive(Debug, Serialize, Deserialize)]
258pub struct UpsertRecordsResponse {
259 pub version: u64,
260 pub results: Vec<UpsertResultDto>,
261}
262
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264pub struct RecordPatchDto {
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub bot_id: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub session_id: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub tenant: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub source: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub state_metadata: Option<StateMetadataDto>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub metadata: Option<Value>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub relationships: Option<Vec<RelationshipDto>>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub expires_at: Option<DateTime<Utc>>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub retention_policy: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub lifecycle_status: Option<String>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub retired_at: Option<DateTime<Utc>>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub retired_reason: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub embedding: Option<Vec<f32>>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub payload_uri: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub payload_size: Option<i64>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub payload_checksum: Option<String>,
297}
298
299impl RecordPatchDto {
300 #[must_use]
301 pub fn is_empty(&self) -> bool {
302 self.bot_id.is_none()
303 && self.session_id.is_none()
304 && self.tenant.is_none()
305 && self.source.is_none()
306 && self.state_metadata.is_none()
307 && self.metadata.is_none()
308 && self.relationships.is_none()
309 && self.expires_at.is_none()
310 && self.retention_policy.is_none()
311 && self.lifecycle_status.is_none()
312 && self.retired_at.is_none()
313 && self.retired_reason.is_none()
314 && self.embedding.is_none()
315 && self.payload_uri.is_none()
316 && self.payload_size.is_none()
317 && self.payload_checksum.is_none()
318 }
319}
320
321#[derive(Debug, Serialize, Deserialize)]
322pub struct UpdateRecordRequest {
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub id: Option<String>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub external_id: Option<String>,
327 #[serde(default)]
328 pub patch: RecordPatchDto,
329}
330
331#[derive(Debug, Serialize, Deserialize)]
332pub struct UpdateRecordResponse {
333 pub version: u64,
334 pub updated: bool,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub replaced_id: Option<String>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub record: Option<RecordDto>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct RecordDto {
343 pub id: String,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub external_id: Option<String>,
346 pub run_id: String,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub bot_id: Option<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub session_id: Option<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub tenant: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub source: Option<String>,
355 pub created_at: DateTime<Utc>,
356 pub role: String,
357 pub content_type: String,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub text_payload: Option<String>,
360 #[serde(
361 default,
362 skip_serializing_if = "Option::is_none",
363 serialize_with = "serialize_base64_opt",
364 deserialize_with = "deserialize_base64_opt"
365 )]
366 pub binary_payload: Option<Vec<u8>>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub payload_uri: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub payload_size: Option<i64>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub payload_checksum: Option<String>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub embedding: Option<Vec<f32>>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub state_metadata: Option<StateMetadataDto>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub metadata: Option<Value>,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
380 pub relationships: Vec<RelationshipDto>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub expires_at: Option<DateTime<Utc>>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub retention_policy: Option<String>,
385 pub lifecycle_status: String,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub retired_at: Option<DateTime<Utc>>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub retired_reason: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub supersedes_id: Option<String>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub superseded_by_id: Option<String>,
394}
395
396#[derive(Debug, Serialize, Deserialize)]
397pub struct ListRecordsResponse {
398 pub records: Vec<RecordDto>,
399}
400
401#[derive(Debug, Serialize, Deserialize)]
406pub struct GetRecordResponse {
407 pub record: Option<RecordDto>,
408}
409
410#[derive(Debug, Serialize, Deserialize)]
411pub struct DeleteRecordResponse {
412 pub deleted: bool,
413 pub version: u64,
414}
415
416#[derive(Debug, Serialize, Deserialize)]
421pub struct SearchRequest {
422 pub query: Vec<f32>,
423 #[serde(default = "default_search_limit")]
424 pub limit: usize,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub filters: Option<Value>,
427 #[serde(default)]
428 pub include_expired: bool,
429 #[serde(default)]
430 pub include_retired: bool,
431 #[serde(default)]
432 pub include_relationships: bool,
433}
434
435#[derive(Debug, Serialize, Deserialize)]
436pub struct SearchResultDto {
437 pub record: RecordDto,
438 pub distance: f32,
439}
440
441#[derive(Debug, Serialize, Deserialize)]
442pub struct SearchResponse {
443 pub results: Vec<SearchResultDto>,
444}
445
446#[derive(Debug, Serialize, Deserialize)]
451pub struct RetrieveRequest {
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub text: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub vector: Option<Vec<f32>>,
456 #[serde(default = "default_search_limit")]
457 pub limit: usize,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub filters: Option<Value>,
460 #[serde(default)]
461 pub include_expired: bool,
462 #[serde(default)]
463 pub include_retired: bool,
464 #[serde(default)]
465 pub include_relationships: bool,
466 #[serde(default = "default_retrieve_fusion")]
467 pub fusion: String,
468}
469
470#[derive(Debug, Serialize, Deserialize)]
471pub struct RetrieveResultDto {
472 pub record: RecordDto,
473 pub score: f32,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub vector_distance: Option<f32>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub text_score: Option<f32>,
478 #[serde(default, skip_serializing_if = "Vec::is_empty")]
479 pub matched_channels: Vec<String>,
480}
481
482#[derive(Debug, Serialize, Deserialize)]
483pub struct RetrieveResponse {
484 pub results: Vec<RetrieveResultDto>,
485}
486
487#[derive(Debug, Serialize, Deserialize)]
492pub struct VersionResponse {
493 pub version: u64,
494}
495
496#[derive(Debug, Serialize, Deserialize)]
497pub struct CheckoutRequest {
498 pub version: u64,
499}
500
501#[derive(Debug, Default, Serialize, Deserialize)]
506pub struct CompactRequest {
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub target_rows_per_fragment: Option<usize>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub materialize_deletions: Option<bool>,
511}
512
513#[derive(Debug, Serialize, Deserialize)]
514pub struct CompactResponse {
515 pub fragments_removed: usize,
516 pub fragments_added: usize,
517 pub files_removed: usize,
518 pub files_added: usize,
519}
520
521#[derive(Debug, Serialize, Deserialize)]
522pub struct CompactStatsResponse {
523 pub total_fragments: usize,
524 pub is_compacting: bool,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub last_compaction: Option<DateTime<Utc>>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub last_error: Option<String>,
529 pub total_compactions: u64,
530}
531
532#[derive(Debug, Serialize, Deserialize)]
537pub struct ErrorBody {
538 pub code: String,
539 pub message: String,
540}
541
542#[derive(Debug, Serialize, Deserialize)]
543pub struct ErrorResponse {
544 pub error: ErrorBody,
545}
546
547fn default_content_type() -> String {
552 "text/plain".to_string()
553}
554
555fn default_role() -> String {
556 "user".to_string()
557}
558
559fn default_upsert_key() -> String {
560 "external_id".to_string()
561}
562
563fn default_search_limit() -> usize {
564 10
565}
566
567fn default_retrieve_fusion() -> String {
568 "rrf".to_string()
569}
570
571fn serialize_base64_opt<S>(data: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
572where
573 S: serde::Serializer,
574{
575 match data {
576 Some(bytes) => serializer.serialize_some(&BASE64.encode(bytes)),
577 None => serializer.serialize_none(),
578 }
579}
580
581fn deserialize_base64_opt<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
582where
583 D: serde::Deserializer<'de>,
584{
585 let opt: Option<String> = Option::deserialize(deserializer)?;
586 match opt {
587 Some(s) => BASE64
588 .decode(&s)
589 .map(Some)
590 .map_err(serde::de::Error::custom),
591 None => Ok(None),
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 #[test]
600 fn search_request_legacy_payload_defaults_filters_and_lifecycle() {
601 let req: SearchRequest =
603 serde_json::from_str(r#"{"query": [0.1, 0.2], "limit": 5}"#).unwrap();
604 assert_eq!(req.query, vec![0.1, 0.2]);
605 assert_eq!(req.limit, 5);
606 assert!(req.filters.is_none());
607 assert!(!req.include_expired);
608 assert!(!req.include_retired);
609 assert!(!req.include_relationships);
610 }
611
612 #[test]
613 fn search_request_defaults_limit_when_omitted() {
614 let req: SearchRequest = serde_json::from_str(r#"{"query": [1.0]}"#).unwrap();
615 assert_eq!(req.limit, default_search_limit());
616 }
617
618 #[test]
619 fn search_request_parses_filters_and_lifecycle() {
620 let req: SearchRequest = serde_json::from_str(
621 r#"{"query": [1.0], "filters": {"tenant": "acme"}, "include_expired": true, "include_retired": true}"#,
622 )
623 .unwrap();
624 assert_eq!(req.filters, Some(serde_json::json!({"tenant": "acme"})));
625 assert!(req.include_expired);
626 assert!(req.include_retired);
627 }
628
629 #[test]
630 fn add_request_omits_payload_reference_when_absent() {
631 let req = AddRecordRequest {
634 role: "user".to_string(),
635 content_type: "text/plain".to_string(),
636 text_payload: Some("hi".to_string()),
637 ..Default::default()
638 };
639 let json = serde_json::to_string(&req).unwrap();
640 assert!(!json.contains("payload_uri"));
641 assert!(!json.contains("payload_size"));
642 assert!(!json.contains("payload_checksum"));
643 }
644
645 #[test]
646 fn add_request_roundtrips_payload_reference() {
647 let req = AddRecordRequest {
648 role: "user".to_string(),
649 content_type: "image/png".to_string(),
650 payload_uri: Some("gs://bucket/prefix/obj.png".to_string()),
651 payload_size: Some(2048),
652 payload_checksum: Some("sha256:abc".to_string()),
653 ..Default::default()
654 };
655 let json = serde_json::to_string(&req).unwrap();
656 let back: AddRecordRequest = serde_json::from_str(&json).unwrap();
657 assert_eq!(
658 back.payload_uri.as_deref(),
659 Some("gs://bucket/prefix/obj.png")
660 );
661 assert_eq!(back.payload_size, Some(2048));
662 assert_eq!(back.payload_checksum.as_deref(), Some("sha256:abc"));
663 }
664
665 #[test]
666 fn record_dto_decodes_payload_reference_and_legacy_shape() {
667 let dto: RecordDto = serde_json::from_str(
669 r#"{"id":"r1","run_id":"run","created_at":"2026-06-27T00:00:00Z","role":"user","content_type":"image/png","lifecycle_status":"active","payload_uri":"s3://b/obj","payload_size":10}"#,
670 )
671 .unwrap();
672 assert_eq!(dto.payload_uri.as_deref(), Some("s3://b/obj"));
673 assert_eq!(dto.payload_size, Some(10));
674 assert_eq!(dto.payload_checksum, None);
675
676 let legacy: RecordDto = serde_json::from_str(
678 r#"{"id":"r1","run_id":"run","created_at":"2026-06-27T00:00:00Z","role":"user","content_type":"text/plain","lifecycle_status":"active"}"#,
679 )
680 .unwrap();
681 assert_eq!(legacy.payload_uri, None);
682 }
683}