Skip to main content

kindling_service/
export.rs

1//! Export / import bundle coordination.
2//!
3//! Ports the TS export pipeline that lived in
4//! `packages/kindling-core/src/export/{bundle.ts,restore.ts}` plus the
5//! store-level primitives in
6//! `packages/kindling-store-sqlite/src/store/export.ts`. The JSON shape of
7//! [`ExportBundle`] is byte-compatible with the TS `ExportBundle` so a bundle
8//! produced here round-trips through the TS importer and vice versa.
9//!
10//! Deferred from PORT-006 (the service deliberately shipped without
11//! export/import); owned here by PORT-012 because the CLI is the only consumer.
12//!
13//! # Key-ordering parity
14//!
15//! `serde_json` is built with `preserve_order` (enabled in this crate's
16//! `Cargo.toml`), so struct fields serialize in declaration order. The field
17//! order below mirrors the object-literal construction order in the TS source:
18//!
19//! * dataset: `version, exportedAt, scope, observations, capsules, summaries,
20//!   pins` (the `exportDatabase` return literal).
21//! * bundle: `bundleVersion, exportedAt, dataset` with `metadata` appended
22//!   **after** `dataset` (TS sets `bundle.metadata` only when present, after the
23//!   literal is built).
24//!
25//! `scope` and `metadata` are omitted entirely when absent — matching TS, which
26//! never serializes them as `null` (an undefined property is dropped by
27//! `JSON.stringify`).
28
29use serde::{Deserialize, Serialize};
30
31use kindling_types::{Capsule, Observation, Pin, ScopeIds, Summary, Timestamp};
32
33use crate::error::ServiceResult;
34use crate::KindlingService;
35
36/// Bundle format version. Mirrors the TS `bundleVersion`/`version` literals.
37pub const BUNDLE_VERSION: &str = "1.0";
38
39/// Options for [`KindlingService::export`].
40///
41/// Mirrors the union of TS `ExportBundleOptions` (`scope`, `metadata`) and the
42/// store-level `ExportOptions` (`includeRedacted`, `limit`). `exported_at` is
43/// injected explicitly (rather than read from a clock) so exports are
44/// deterministic and testable — the CLI passes the timestamp it stamps into the
45/// default output filename.
46#[derive(Debug, Clone, Default)]
47pub struct ExportBundleOptions {
48    /// Optional scope filter applied to every entity.
49    pub scope: Option<ScopeIds>,
50    /// Include redacted observations (TS default: false).
51    pub include_redacted: bool,
52    /// Maximum observations to export.
53    pub limit: Option<u32>,
54    /// Optional bundle metadata (serialized verbatim as a JSON object).
55    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
56    /// Export timestamp stamped into both the bundle and the dataset.
57    pub exported_at: Timestamp,
58}
59
60/// The entity dataset inside an [`ExportBundle`]. Mirrors the TS `ExportDataset`
61/// shape returned by `exportDatabase`.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct ExportDataset {
65    /// Schema version for forward compatibility (`"1.0"`).
66    pub version: String,
67    /// Export timestamp (epoch ms).
68    pub exported_at: Timestamp,
69    /// Scope filter applied, when any. Omitted from JSON when absent.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub scope: Option<ScopeIds>,
72    /// Observations ordered `ts ASC, id ASC`.
73    pub observations: Vec<Observation>,
74    /// Capsules ordered `opened_at ASC, id ASC`.
75    pub capsules: Vec<Capsule>,
76    /// Summaries ordered `created_at ASC, id ASC`.
77    pub summaries: Vec<Summary>,
78    /// Pins ordered `created_at ASC, id ASC`.
79    pub pins: Vec<Pin>,
80}
81
82/// A portable export bundle. JSON-compatible with the TS `ExportBundle`.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct ExportBundle {
86    /// Bundle format version (`"1.0"`).
87    pub bundle_version: String,
88    /// Export timestamp (epoch ms).
89    pub exported_at: Timestamp,
90    /// The entity dataset.
91    pub dataset: ExportDataset,
92    /// Optional metadata. Declared **after** `dataset` to match the TS field
93    /// order; omitted from JSON when absent.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
96}
97
98/// Summary statistics for an [`ExportBundle`]. Mirrors the TS `ExportStats`.
99#[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    /// Length of the compact JSON serialization (`JSON.stringify(bundle).length`
107    /// in TS — UTF-16 code units there, bytes here; equal for ASCII-only data).
108    pub total_size: usize,
109}
110
111/// Options for [`KindlingService::import`]. Mirrors the TS `ImportOptions`.
112#[derive(Debug, Clone, Default)]
113pub struct ImportOptions {
114    /// Validate only; do not write. Mirrors the TS `dryRun`.
115    pub dry_run: bool,
116}
117
118/// Result of an import. Mirrors the TS `ImportResult`, including the `dryRun`
119/// flag echoed back.
120#[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    /// Serialize to JSON. `pretty` switches between compact (single line, TS
133    /// `JSON.stringify(bundle, null, 0)`) and 2-space pretty printing.
134    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    /// Parse a bundle from JSON, validating its structure (version + required
144    /// arrays). Mirrors `deserializeBundle` + `validateBundle`.
145    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    /// Structural validation. Errors mirror the messages produced by the TS
152    /// `validateBundle` for the version checks (the array/required-field checks
153    /// are enforced statically by the typed deserialization above).
154    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    /// Bundle statistics (entity counts + serialized size). Mirrors
177    /// `getBundleStats`.
178    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    /// Build an export bundle from the store. Ports `createExportBundle`:
192    /// reads each entity table in deterministic order, applies the optional
193    /// scope/redaction/limit filters, and wraps the dataset with bundle
194    /// metadata.
195    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    /// Restore a bundle into the store. Ports `restoreFromBundle` +
223    /// `importDatabase`: validates structure, short-circuits on `dry_run`, and
224    /// otherwise imports every entity in a single transaction with
225    /// `INSERT OR IGNORE` semantics (existing ids are skipped, not overwritten).
226    /// Per-row failures are collected into `errors` rather than aborting.
227    pub fn import(
228        &self,
229        bundle: &ExportBundle,
230        options: ImportOptions,
231    ) -> ServiceResult<ImportResult> {
232        // Validate structure first (matches restoreFromBundle's pre-check). On
233        // a bad version, TS returns the validation errors with zero counts
234        // rather than throwing.
235        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        // Re-exporting the destination yields the same dataset.
397        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        // Nothing persisted.
426        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}