1use serde::{Deserialize, Serialize};
30
31use kindling_types::{Capsule, Observation, Pin, ScopeIds, Summary, Timestamp};
32
33use crate::error::ServiceResult;
34use crate::KindlingService;
35
36pub const BUNDLE_VERSION: &str = "1.0";
38
39#[derive(Debug, Clone, Default)]
47pub struct ExportBundleOptions {
48 pub scope: Option<ScopeIds>,
50 pub include_redacted: bool,
52 pub limit: Option<u32>,
54 pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
56 pub exported_at: Timestamp,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct ExportDataset {
65 pub version: String,
67 pub exported_at: Timestamp,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub scope: Option<ScopeIds>,
72 pub observations: Vec<Observation>,
74 pub capsules: Vec<Capsule>,
76 pub summaries: Vec<Summary>,
78 pub pins: Vec<Pin>,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct ExportBundle {
86 pub bundle_version: String,
88 pub exported_at: Timestamp,
90 pub dataset: ExportDataset,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct ExportStats {
102 pub observations: usize,
103 pub capsules: usize,
104 pub summaries: usize,
105 pub pins: usize,
106 pub total_size: usize,
109}
110
111#[derive(Debug, Clone, Default)]
113pub struct ImportOptions {
114 pub dry_run: bool,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct ImportResult {
123 pub observations: usize,
124 pub capsules: usize,
125 pub summaries: usize,
126 pub pins: usize,
127 pub errors: Vec<String>,
128 pub dry_run: bool,
129}
130
131impl ExportBundle {
132 pub fn to_json(&self, pretty: bool) -> ServiceResult<String> {
135 let json = if pretty {
136 serde_json::to_string_pretty(self)?
137 } else {
138 serde_json::to_string(self)?
139 };
140 Ok(json)
141 }
142
143 pub fn from_json(json: &str) -> ServiceResult<Self> {
146 let bundle: ExportBundle = serde_json::from_str(json)?;
147 bundle.validate()?;
148 Ok(bundle)
149 }
150
151 pub fn validate(&self) -> ServiceResult<()> {
155 if self.bundle_version != BUNDLE_VERSION {
156 return Err(crate::ServiceError::Validation(vec![
157 kindling_types::ValidationError {
158 field: "bundleVersion".to_string(),
159 message: format!("Unsupported bundle version: {}", self.bundle_version),
160 value: None,
161 },
162 ]));
163 }
164 if self.dataset.version != BUNDLE_VERSION {
165 return Err(crate::ServiceError::Validation(vec![
166 kindling_types::ValidationError {
167 field: "dataset.version".to_string(),
168 message: format!("Unsupported schema version: {}", self.dataset.version),
169 value: None,
170 },
171 ]));
172 }
173 Ok(())
174 }
175
176 pub fn stats(&self) -> ServiceResult<ExportStats> {
179 let total_size = serde_json::to_string(self)?.len();
180 Ok(ExportStats {
181 observations: self.dataset.observations.len(),
182 capsules: self.dataset.capsules.len(),
183 summaries: self.dataset.summaries.len(),
184 pins: self.dataset.pins.len(),
185 total_size,
186 })
187 }
188}
189
190impl KindlingService {
191 pub fn export(&self, options: ExportBundleOptions) -> ServiceResult<ExportBundle> {
196 let scope = options.scope.as_ref();
197 let observations =
198 self.store()
199 .export_observations(scope, options.include_redacted, options.limit)?;
200 let capsules = self.store().export_capsules(scope)?;
201 let summaries = self.store().export_summaries(scope)?;
202 let pins = self.store().export_pins(scope)?;
203
204 let dataset = ExportDataset {
205 version: BUNDLE_VERSION.to_string(),
206 exported_at: options.exported_at,
207 scope: options.scope,
208 observations,
209 capsules,
210 summaries,
211 pins,
212 };
213
214 Ok(ExportBundle {
215 bundle_version: BUNDLE_VERSION.to_string(),
216 exported_at: options.exported_at,
217 dataset,
218 metadata: options.metadata,
219 })
220 }
221
222 pub fn import(
228 &self,
229 bundle: &ExportBundle,
230 options: ImportOptions,
231 ) -> ServiceResult<ImportResult> {
232 if let Err(crate::ServiceError::Validation(errors)) = bundle.validate() {
236 return Ok(ImportResult {
237 observations: 0,
238 capsules: 0,
239 summaries: 0,
240 pins: 0,
241 errors: errors.into_iter().map(|e| e.message).collect(),
242 dry_run: options.dry_run,
243 });
244 }
245
246 if options.dry_run {
247 return Ok(ImportResult {
248 observations: bundle.dataset.observations.len(),
249 capsules: bundle.dataset.capsules.len(),
250 summaries: bundle.dataset.summaries.len(),
251 pins: bundle.dataset.pins.len(),
252 errors: Vec::new(),
253 dry_run: true,
254 });
255 }
256
257 let mut errors: Vec<String> = Vec::new();
258 let result = self.store().transaction(|store| {
259 let mut observations = 0usize;
260 let mut capsules = 0usize;
261 let mut summaries = 0usize;
262 let mut pins = 0usize;
263
264 for obs in &bundle.dataset.observations {
265 match store.import_observation(obs) {
266 Ok(true) => observations += 1,
267 Ok(false) => {}
268 Err(err) => {
269 errors.push(format!("Failed to import observation {}: {err}", obs.id))
270 }
271 }
272 }
273 for capsule in &bundle.dataset.capsules {
274 match store.import_capsule(capsule) {
275 Ok(true) => capsules += 1,
276 Ok(false) => {}
277 Err(err) => {
278 errors.push(format!("Failed to import capsule {}: {err}", capsule.id))
279 }
280 }
281 }
282 for summary in &bundle.dataset.summaries {
283 match store.import_summary(summary) {
284 Ok(true) => summaries += 1,
285 Ok(false) => {}
286 Err(err) => {
287 errors.push(format!("Failed to import summary {}: {err}", summary.id))
288 }
289 }
290 }
291 for pin in &bundle.dataset.pins {
292 match store.import_pin(pin) {
293 Ok(true) => pins += 1,
294 Ok(false) => {}
295 Err(err) => errors.push(format!("Failed to import pin {}: {err}", pin.id)),
296 }
297 }
298
299 Ok((observations, capsules, summaries, pins))
300 })?;
301
302 let (observations, capsules, summaries, pins) = result;
303 Ok(ImportResult {
304 observations,
305 capsules,
306 summaries,
307 pins,
308 errors,
309 dry_run: false,
310 })
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use kindling_types::{
318 Capsule, CapsuleStatus, CapsuleType, Observation, ObservationKind, Pin, PinTargetType,
319 Summary,
320 };
321
322 fn seeded() -> KindlingService {
323 let service = KindlingService::open_in_memory().unwrap();
324 let store = service.store();
325 store
326 .insert_observation(&Observation {
327 id: "o1".into(),
328 kind: ObservationKind::Message,
329 content: "hi".into(),
330 provenance: serde_json::Map::new(),
331 ts: 10,
332 scope_ids: ScopeIds::default(),
333 redacted: false,
334 })
335 .unwrap();
336 store
337 .create_capsule(&Capsule {
338 id: "c1".into(),
339 kind: CapsuleType::Session,
340 intent: "i".into(),
341 status: CapsuleStatus::Closed,
342 opened_at: 5,
343 closed_at: Some(20),
344 scope_ids: ScopeIds::default(),
345 observation_ids: vec![],
346 summary_id: None,
347 })
348 .unwrap();
349 store
350 .insert_summary(&Summary {
351 id: "s1".into(),
352 capsule_id: "c1".into(),
353 content: "sum".into(),
354 confidence: 0.5,
355 created_at: 15,
356 evidence_refs: vec![],
357 })
358 .unwrap();
359 store
360 .insert_pin(&Pin {
361 id: "p1".into(),
362 target_type: PinTargetType::Observation,
363 target_id: "o1".into(),
364 reason: None,
365 created_at: 12,
366 expires_at: None,
367 scope_ids: ScopeIds::default(),
368 })
369 .unwrap();
370 service
371 }
372
373 fn export_opts() -> ExportBundleOptions {
374 ExportBundleOptions {
375 scope: None,
376 include_redacted: false,
377 limit: None,
378 metadata: None,
379 exported_at: 100,
380 }
381 }
382
383 #[test]
384 fn export_then_import_into_fresh_store_round_trips() {
385 let bundle = seeded().export(export_opts()).unwrap();
386
387 let dest = KindlingService::open_in_memory().unwrap();
388 let result = dest.import(&bundle, ImportOptions::default()).unwrap();
389 assert_eq!(result.observations, 1);
390 assert_eq!(result.capsules, 1);
391 assert_eq!(result.summaries, 1);
392 assert_eq!(result.pins, 1);
393 assert!(result.errors.is_empty());
394 assert!(!result.dry_run);
395
396 let re = dest.export(export_opts()).unwrap();
398 assert_eq!(re.dataset.observations, bundle.dataset.observations);
399 assert_eq!(re.dataset.capsules, bundle.dataset.capsules);
400 assert_eq!(re.dataset.summaries, bundle.dataset.summaries);
401 assert_eq!(re.dataset.pins, bundle.dataset.pins);
402 }
403
404 #[test]
405 fn import_is_idempotent() {
406 let bundle = seeded().export(export_opts()).unwrap();
407 let dest = KindlingService::open_in_memory().unwrap();
408 dest.import(&bundle, ImportOptions::default()).unwrap();
409 let again = dest.import(&bundle, ImportOptions::default()).unwrap();
410 assert_eq!(again.observations, 0);
411 assert_eq!(again.capsules, 0);
412 assert_eq!(again.summaries, 0);
413 assert_eq!(again.pins, 0);
414 }
415
416 #[test]
417 fn dry_run_counts_without_writing() {
418 let bundle = seeded().export(export_opts()).unwrap();
419 let dest = KindlingService::open_in_memory().unwrap();
420 let result = dest
421 .import(&bundle, ImportOptions { dry_run: true })
422 .unwrap();
423 assert!(result.dry_run);
424 assert_eq!(result.observations, 1);
425 assert_eq!(dest.store().database_stats().unwrap().observations, 0);
427 }
428
429 #[test]
430 fn bad_version_returns_errors_not_panic() {
431 let mut bundle = seeded().export(export_opts()).unwrap();
432 bundle.dataset.version = "9.9".into();
433 let dest = KindlingService::open_in_memory().unwrap();
434 let result = dest.import(&bundle, ImportOptions::default()).unwrap();
435 assert_eq!(result.observations, 0);
436 assert!(result.errors.iter().any(|e| e.contains("9.9")));
437 }
438
439 #[test]
440 fn export_respects_scope_filter() {
441 let service = KindlingService::open_in_memory().unwrap();
442 let scoped = ScopeIds {
443 session_id: Some("keep".into()),
444 ..Default::default()
445 };
446 let other = ScopeIds {
447 session_id: Some("drop".into()),
448 ..Default::default()
449 };
450 for (id, scope) in [("a", &scoped), ("b", &other)] {
451 service
452 .store()
453 .insert_observation(&Observation {
454 id: id.into(),
455 kind: ObservationKind::Message,
456 content: id.into(),
457 provenance: serde_json::Map::new(),
458 ts: 1,
459 scope_ids: scope.clone(),
460 redacted: false,
461 })
462 .unwrap();
463 }
464 let bundle = service
465 .export(ExportBundleOptions {
466 scope: Some(scoped),
467 ..export_opts()
468 })
469 .unwrap();
470 assert_eq!(bundle.dataset.observations.len(), 1);
471 assert_eq!(bundle.dataset.observations[0].id, "a");
472 }
473}