kindling-service 0.2.0

In-process orchestration layer for kindling memory: capsules, observations, retrieval and pins.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! Export / import bundle coordination.
//!
//! Ports the TS export pipeline that lived in
//! `packages/kindling-core/src/export/{bundle.ts,restore.ts}` plus the
//! store-level primitives in
//! `packages/kindling-store-sqlite/src/store/export.ts`. The JSON shape of
//! [`ExportBundle`] is byte-compatible with the TS `ExportBundle` so a bundle
//! produced here round-trips through the TS importer and vice versa.
//!
//! Deferred from PORT-006 (the service deliberately shipped without
//! export/import); owned here by PORT-012 because the CLI is the only consumer.
//!
//! # Key-ordering parity
//!
//! `serde_json` is built with `preserve_order` (enabled in this crate's
//! `Cargo.toml`), so struct fields serialize in declaration order. The field
//! order below mirrors the object-literal construction order in the TS source:
//!
//! * dataset: `version, exportedAt, scope, observations, capsules, summaries,
//!   pins` (the `exportDatabase` return literal).
//! * bundle: `bundleVersion, exportedAt, dataset` with `metadata` appended
//!   **after** `dataset` (TS sets `bundle.metadata` only when present, after the
//!   literal is built).
//!
//! `scope` and `metadata` are omitted entirely when absent — matching TS, which
//! never serializes them as `null` (an undefined property is dropped by
//! `JSON.stringify`).

use serde::{Deserialize, Serialize};

use kindling_types::{Capsule, Observation, Pin, ScopeIds, Summary, Timestamp};

use crate::error::ServiceResult;
use crate::KindlingService;

/// Bundle format version. Mirrors the TS `bundleVersion`/`version` literals.
pub const BUNDLE_VERSION: &str = "1.0";

/// Options for [`KindlingService::export`].
///
/// Mirrors the union of TS `ExportBundleOptions` (`scope`, `metadata`) and the
/// store-level `ExportOptions` (`includeRedacted`, `limit`). `exported_at` is
/// injected explicitly (rather than read from a clock) so exports are
/// deterministic and testable — the CLI passes the timestamp it stamps into the
/// default output filename.
#[derive(Debug, Clone, Default)]
pub struct ExportBundleOptions {
    /// Optional scope filter applied to every entity.
    pub scope: Option<ScopeIds>,
    /// Include redacted observations (TS default: false).
    pub include_redacted: bool,
    /// Maximum observations to export.
    pub limit: Option<u32>,
    /// Optional bundle metadata (serialized verbatim as a JSON object).
    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
    /// Export timestamp stamped into both the bundle and the dataset.
    pub exported_at: Timestamp,
}

/// The entity dataset inside an [`ExportBundle`]. Mirrors the TS `ExportDataset`
/// shape returned by `exportDatabase`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportDataset {
    /// Schema version for forward compatibility (`"1.0"`).
    pub version: String,
    /// Export timestamp (epoch ms).
    pub exported_at: Timestamp,
    /// Scope filter applied, when any. Omitted from JSON when absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scope: Option<ScopeIds>,
    /// Observations ordered `ts ASC, id ASC`.
    pub observations: Vec<Observation>,
    /// Capsules ordered `opened_at ASC, id ASC`.
    pub capsules: Vec<Capsule>,
    /// Summaries ordered `created_at ASC, id ASC`.
    pub summaries: Vec<Summary>,
    /// Pins ordered `created_at ASC, id ASC`.
    pub pins: Vec<Pin>,
}

/// A portable export bundle. JSON-compatible with the TS `ExportBundle`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportBundle {
    /// Bundle format version (`"1.0"`).
    pub bundle_version: String,
    /// Export timestamp (epoch ms).
    pub exported_at: Timestamp,
    /// The entity dataset.
    pub dataset: ExportDataset,
    /// Optional metadata. Declared **after** `dataset` to match the TS field
    /// order; omitted from JSON when absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
}

/// Summary statistics for an [`ExportBundle`]. Mirrors the TS `ExportStats`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportStats {
    pub observations: usize,
    pub capsules: usize,
    pub summaries: usize,
    pub pins: usize,
    /// Length of the compact JSON serialization (`JSON.stringify(bundle).length`
    /// in TS — UTF-16 code units there, bytes here; equal for ASCII-only data).
    pub total_size: usize,
}

/// Options for [`KindlingService::import`]. Mirrors the TS `ImportOptions`.
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
    /// Validate only; do not write. Mirrors the TS `dryRun`.
    pub dry_run: bool,
}

/// Result of an import. Mirrors the TS `ImportResult`, including the `dryRun`
/// flag echoed back.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportResult {
    pub observations: usize,
    pub capsules: usize,
    pub summaries: usize,
    pub pins: usize,
    pub errors: Vec<String>,
    pub dry_run: bool,
}

impl ExportBundle {
    /// Serialize to JSON. `pretty` switches between compact (single line, TS
    /// `JSON.stringify(bundle, null, 0)`) and 2-space pretty printing.
    pub fn to_json(&self, pretty: bool) -> ServiceResult<String> {
        let json = if pretty {
            serde_json::to_string_pretty(self)?
        } else {
            serde_json::to_string(self)?
        };
        Ok(json)
    }

    /// Parse a bundle from JSON, validating its structure (version + required
    /// arrays). Mirrors `deserializeBundle` + `validateBundle`.
    pub fn from_json(json: &str) -> ServiceResult<Self> {
        let bundle: ExportBundle = serde_json::from_str(json)?;
        bundle.validate()?;
        Ok(bundle)
    }

    /// Structural validation. Errors mirror the messages produced by the TS
    /// `validateBundle` for the version checks (the array/required-field checks
    /// are enforced statically by the typed deserialization above).
    pub fn validate(&self) -> ServiceResult<()> {
        if self.bundle_version != BUNDLE_VERSION {
            return Err(crate::ServiceError::Validation(vec![
                kindling_types::ValidationError {
                    field: "bundleVersion".to_string(),
                    message: format!("Unsupported bundle version: {}", self.bundle_version),
                    value: None,
                },
            ]));
        }
        if self.dataset.version != BUNDLE_VERSION {
            return Err(crate::ServiceError::Validation(vec![
                kindling_types::ValidationError {
                    field: "dataset.version".to_string(),
                    message: format!("Unsupported schema version: {}", self.dataset.version),
                    value: None,
                },
            ]));
        }
        Ok(())
    }

    /// Bundle statistics (entity counts + serialized size). Mirrors
    /// `getBundleStats`.
    pub fn stats(&self) -> ServiceResult<ExportStats> {
        let total_size = serde_json::to_string(self)?.len();
        Ok(ExportStats {
            observations: self.dataset.observations.len(),
            capsules: self.dataset.capsules.len(),
            summaries: self.dataset.summaries.len(),
            pins: self.dataset.pins.len(),
            total_size,
        })
    }
}

impl KindlingService {
    /// Build an export bundle from the store. Ports `createExportBundle`:
    /// reads each entity table in deterministic order, applies the optional
    /// scope/redaction/limit filters, and wraps the dataset with bundle
    /// metadata.
    pub fn export(&self, options: ExportBundleOptions) -> ServiceResult<ExportBundle> {
        let scope = options.scope.as_ref();
        let observations =
            self.store()
                .export_observations(scope, options.include_redacted, options.limit)?;
        let capsules = self.store().export_capsules(scope)?;
        let summaries = self.store().export_summaries(scope)?;
        let pins = self.store().export_pins(scope)?;

        let dataset = ExportDataset {
            version: BUNDLE_VERSION.to_string(),
            exported_at: options.exported_at,
            scope: options.scope,
            observations,
            capsules,
            summaries,
            pins,
        };

        Ok(ExportBundle {
            bundle_version: BUNDLE_VERSION.to_string(),
            exported_at: options.exported_at,
            dataset,
            metadata: options.metadata,
        })
    }

    /// Restore a bundle into the store. Ports `restoreFromBundle` +
    /// `importDatabase`: validates structure, short-circuits on `dry_run`, and
    /// otherwise imports every entity in a single transaction with
    /// `INSERT OR IGNORE` semantics (existing ids are skipped, not overwritten).
    /// Per-row failures are collected into `errors` rather than aborting.
    pub fn import(
        &self,
        bundle: &ExportBundle,
        options: ImportOptions,
    ) -> ServiceResult<ImportResult> {
        // Validate structure first (matches restoreFromBundle's pre-check). On
        // a bad version, TS returns the validation errors with zero counts
        // rather than throwing.
        if let Err(crate::ServiceError::Validation(errors)) = bundle.validate() {
            return Ok(ImportResult {
                observations: 0,
                capsules: 0,
                summaries: 0,
                pins: 0,
                errors: errors.into_iter().map(|e| e.message).collect(),
                dry_run: options.dry_run,
            });
        }

        if options.dry_run {
            return Ok(ImportResult {
                observations: bundle.dataset.observations.len(),
                capsules: bundle.dataset.capsules.len(),
                summaries: bundle.dataset.summaries.len(),
                pins: bundle.dataset.pins.len(),
                errors: Vec::new(),
                dry_run: true,
            });
        }

        let mut errors: Vec<String> = Vec::new();
        let result = self.store().transaction(|store| {
            let mut observations = 0usize;
            let mut capsules = 0usize;
            let mut summaries = 0usize;
            let mut pins = 0usize;

            for obs in &bundle.dataset.observations {
                match store.import_observation(obs) {
                    Ok(true) => observations += 1,
                    Ok(false) => {}
                    Err(err) => {
                        errors.push(format!("Failed to import observation {}: {err}", obs.id))
                    }
                }
            }
            for capsule in &bundle.dataset.capsules {
                match store.import_capsule(capsule) {
                    Ok(true) => capsules += 1,
                    Ok(false) => {}
                    Err(err) => {
                        errors.push(format!("Failed to import capsule {}: {err}", capsule.id))
                    }
                }
            }
            for summary in &bundle.dataset.summaries {
                match store.import_summary(summary) {
                    Ok(true) => summaries += 1,
                    Ok(false) => {}
                    Err(err) => {
                        errors.push(format!("Failed to import summary {}: {err}", summary.id))
                    }
                }
            }
            for pin in &bundle.dataset.pins {
                match store.import_pin(pin) {
                    Ok(true) => pins += 1,
                    Ok(false) => {}
                    Err(err) => errors.push(format!("Failed to import pin {}: {err}", pin.id)),
                }
            }

            Ok((observations, capsules, summaries, pins))
        })?;

        let (observations, capsules, summaries, pins) = result;
        Ok(ImportResult {
            observations,
            capsules,
            summaries,
            pins,
            errors,
            dry_run: false,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use kindling_types::{
        Capsule, CapsuleStatus, CapsuleType, Observation, ObservationKind, Pin, PinTargetType,
        Summary,
    };

    fn seeded() -> KindlingService {
        let service = KindlingService::open_in_memory().unwrap();
        let store = service.store();
        store
            .insert_observation(&Observation {
                id: "o1".into(),
                kind: ObservationKind::Message,
                content: "hi".into(),
                provenance: serde_json::Map::new(),
                ts: 10,
                scope_ids: ScopeIds::default(),
                redacted: false,
            })
            .unwrap();
        store
            .create_capsule(&Capsule {
                id: "c1".into(),
                kind: CapsuleType::Session,
                intent: "i".into(),
                status: CapsuleStatus::Closed,
                opened_at: 5,
                closed_at: Some(20),
                scope_ids: ScopeIds::default(),
                observation_ids: vec![],
                summary_id: None,
            })
            .unwrap();
        store
            .insert_summary(&Summary {
                id: "s1".into(),
                capsule_id: "c1".into(),
                content: "sum".into(),
                confidence: 0.5,
                created_at: 15,
                evidence_refs: vec![],
            })
            .unwrap();
        store
            .insert_pin(&Pin {
                id: "p1".into(),
                target_type: PinTargetType::Observation,
                target_id: "o1".into(),
                reason: None,
                created_at: 12,
                expires_at: None,
                scope_ids: ScopeIds::default(),
            })
            .unwrap();
        service
    }

    fn export_opts() -> ExportBundleOptions {
        ExportBundleOptions {
            scope: None,
            include_redacted: false,
            limit: None,
            metadata: None,
            exported_at: 100,
        }
    }

    #[test]
    fn export_then_import_into_fresh_store_round_trips() {
        let bundle = seeded().export(export_opts()).unwrap();

        let dest = KindlingService::open_in_memory().unwrap();
        let result = dest.import(&bundle, ImportOptions::default()).unwrap();
        assert_eq!(result.observations, 1);
        assert_eq!(result.capsules, 1);
        assert_eq!(result.summaries, 1);
        assert_eq!(result.pins, 1);
        assert!(result.errors.is_empty());
        assert!(!result.dry_run);

        // Re-exporting the destination yields the same dataset.
        let re = dest.export(export_opts()).unwrap();
        assert_eq!(re.dataset.observations, bundle.dataset.observations);
        assert_eq!(re.dataset.capsules, bundle.dataset.capsules);
        assert_eq!(re.dataset.summaries, bundle.dataset.summaries);
        assert_eq!(re.dataset.pins, bundle.dataset.pins);
    }

    #[test]
    fn import_is_idempotent() {
        let bundle = seeded().export(export_opts()).unwrap();
        let dest = KindlingService::open_in_memory().unwrap();
        dest.import(&bundle, ImportOptions::default()).unwrap();
        let again = dest.import(&bundle, ImportOptions::default()).unwrap();
        assert_eq!(again.observations, 0);
        assert_eq!(again.capsules, 0);
        assert_eq!(again.summaries, 0);
        assert_eq!(again.pins, 0);
    }

    #[test]
    fn dry_run_counts_without_writing() {
        let bundle = seeded().export(export_opts()).unwrap();
        let dest = KindlingService::open_in_memory().unwrap();
        let result = dest
            .import(&bundle, ImportOptions { dry_run: true })
            .unwrap();
        assert!(result.dry_run);
        assert_eq!(result.observations, 1);
        // Nothing persisted.
        assert_eq!(dest.store().database_stats().unwrap().observations, 0);
    }

    #[test]
    fn bad_version_returns_errors_not_panic() {
        let mut bundle = seeded().export(export_opts()).unwrap();
        bundle.dataset.version = "9.9".into();
        let dest = KindlingService::open_in_memory().unwrap();
        let result = dest.import(&bundle, ImportOptions::default()).unwrap();
        assert_eq!(result.observations, 0);
        assert!(result.errors.iter().any(|e| e.contains("9.9")));
    }

    #[test]
    fn export_respects_scope_filter() {
        let service = KindlingService::open_in_memory().unwrap();
        let scoped = ScopeIds {
            session_id: Some("keep".into()),
            ..Default::default()
        };
        let other = ScopeIds {
            session_id: Some("drop".into()),
            ..Default::default()
        };
        for (id, scope) in [("a", &scoped), ("b", &other)] {
            service
                .store()
                .insert_observation(&Observation {
                    id: id.into(),
                    kind: ObservationKind::Message,
                    content: id.into(),
                    provenance: serde_json::Map::new(),
                    ts: 1,
                    scope_ids: scope.clone(),
                    redacted: false,
                })
                .unwrap();
        }
        let bundle = service
            .export(ExportBundleOptions {
                scope: Some(scoped),
                ..export_opts()
            })
            .unwrap();
        assert_eq!(bundle.dataset.observations.len(), 1);
        assert_eq!(bundle.dataset.observations[0].id, "a");
    }
}