Skip to main content

kindling_store/
store.rs

1//! SQLite-backed kindling store.
2//!
3//! Mirrors `SqliteKindlingStore` in
4//! `packages/kindling-store-sqlite/src/store/sqlite.ts` method-for-method.
5//! FTS index sync happens automatically via the triggers defined in the
6//! schema contract; no method here touches the FTS tables directly.
7
8use std::path::Path;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rusqlite::types::Value as SqlValue;
12use rusqlite::{named_params, params_from_iter, Connection, OptionalExtension};
13
14use kindling_types::{
15    Capsule, CapsuleStatus, CapsuleType, Observation, ObservationKind, Pin, PinTargetType,
16    ScopeIds, Summary, Timestamp,
17};
18
19use crate::db::{open_database, open_in_memory, StoreOptions};
20use crate::error::{StoreError, StoreResult};
21
22/// Evidence snippet with context. Mirrors `EvidenceSnippet` in the TS store.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct EvidenceSnippet {
25    pub observation_id: String,
26    pub snippet: String,
27    pub kind: ObservationKind,
28}
29
30/// Default maximum snippet length used by [`SqliteKindlingStore::get_evidence_snippets`]
31/// callers that have no opinion (matches the TS default parameter).
32pub const DEFAULT_SNIPPET_MAX_CHARS: usize = 200;
33
34/// SQLite-based kindling store.
35pub struct SqliteKindlingStore {
36    conn: Connection,
37}
38
39impl SqliteKindlingStore {
40    /// Open (and initialize if fresh) the database at `path`.
41    pub fn open(path: &Path) -> StoreResult<Self> {
42        Self::open_with_options(path, &StoreOptions::default())
43    }
44
45    /// Open the database at `path` with explicit options.
46    pub fn open_with_options(path: &Path, options: &StoreOptions) -> StoreResult<Self> {
47        Ok(Self {
48            conn: open_database(path, options)?,
49        })
50    }
51
52    /// Open a fresh in-memory store (test/scratch use).
53    pub fn open_in_memory() -> StoreResult<Self> {
54        Ok(Self {
55            conn: open_in_memory()?,
56        })
57    }
58
59    /// Wrap an already-configured connection (advanced use).
60    pub fn from_connection(conn: Connection) -> Self {
61        Self { conn }
62    }
63
64    /// Borrow the underlying connection (e.g. for the retrieval provider).
65    pub fn connection(&self) -> &Connection {
66        &self.conn
67    }
68
69    // ===== WRITE PATH =====
70
71    /// Insert an observation if its id is not already present. FTS sync happens
72    /// automatically via triggers.
73    ///
74    /// Uses `INSERT OR IGNORE` (id-conflict → no-op), making replay idempotent:
75    /// an observation whose id already exists is left untouched, so a spool
76    /// replay after a crash cannot store a duplicate or overwrite the stored
77    /// row. Returns `true` when a NEW row was written and `false` when the id
78    /// already existed and the insert was ignored.
79    pub fn insert_observation(&self, observation: &Observation) -> StoreResult<bool> {
80        let changes = self.conn.execute(
81            "INSERT OR IGNORE INTO observations (
82               id, kind, content, provenance, ts, scope_ids, redacted,
83               session_id, repo_id, agent_id, user_id
84             ) VALUES (
85               :id, :kind, :content, :provenance, :ts, :scope_ids, :redacted,
86               :session_id, :repo_id, :agent_id, :user_id
87             )",
88            named_params! {
89                ":id": observation.id,
90                ":kind": observation_kind_to_str(observation.kind),
91                ":content": observation.content,
92                ":provenance": serde_json::to_string(&observation.provenance)?,
93                ":ts": observation.ts,
94                ":scope_ids": serde_json::to_string(&observation.scope_ids)?,
95                ":redacted": observation.redacted,
96                ":session_id": observation.scope_ids.session_id,
97                ":repo_id": observation.scope_ids.repo_id,
98                ":agent_id": observation.scope_ids.agent_id,
99                ":user_id": observation.scope_ids.user_id,
100            },
101        )?;
102        Ok(changes > 0)
103    }
104
105    /// Create a new capsule.
106    pub fn create_capsule(&self, capsule: &Capsule) -> StoreResult<()> {
107        self.conn.execute(
108            "INSERT INTO capsules (
109               id, type, intent, status, opened_at, closed_at, scope_ids,
110               session_id, repo_id, agent_id, user_id
111             ) VALUES (
112               :id, :type, :intent, :status, :opened_at, :closed_at, :scope_ids,
113               :session_id, :repo_id, :agent_id, :user_id
114             )",
115            named_params! {
116                ":id": capsule.id,
117                ":type": capsule_type_to_str(capsule.kind),
118                ":intent": capsule.intent,
119                ":status": capsule_status_to_str(capsule.status),
120                ":opened_at": capsule.opened_at,
121                ":closed_at": capsule.closed_at,
122                ":scope_ids": serde_json::to_string(&capsule.scope_ids)?,
123                ":session_id": capsule.scope_ids.session_id,
124                ":repo_id": capsule.scope_ids.repo_id,
125                ":agent_id": capsule.scope_ids.agent_id,
126                ":user_id": capsule.scope_ids.user_id,
127            },
128        )?;
129        Ok(())
130    }
131
132    /// Close a capsule: set status to `closed` and stamp `closed_at`
133    /// (defaults to now). Errors if the capsule is missing or already closed.
134    /// When `summary_id` is given, validates that the summary exists for this
135    /// capsule.
136    pub fn close_capsule(
137        &self,
138        capsule_id: &str,
139        closed_at: Option<Timestamp>,
140        summary_id: Option<&str>,
141    ) -> StoreResult<()> {
142        let changes = self.conn.execute(
143            "UPDATE capsules
144             SET status = 'closed', closed_at = :closed_at
145             WHERE id = :id AND status = 'open'",
146            named_params! {
147                ":id": capsule_id,
148                ":closed_at": closed_at.unwrap_or_else(now_ms),
149            },
150        )?;
151        if changes == 0 {
152            return Err(StoreError::CapsuleNotOpen(capsule_id.to_string()));
153        }
154
155        if let Some(summary_id) = summary_id {
156            let exists: Option<String> = self
157                .conn
158                .query_row(
159                    "SELECT id FROM summaries WHERE id = ?1 AND capsule_id = ?2",
160                    [summary_id, capsule_id],
161                    |row| row.get(0),
162                )
163                .optional()?;
164            if exists.is_none() {
165                return Err(StoreError::SummaryNotFound {
166                    summary_id: summary_id.to_string(),
167                    capsule_id: capsule_id.to_string(),
168                });
169            }
170        }
171        Ok(())
172    }
173
174    /// Attach an observation to a capsule, appending at the next `seq` to
175    /// keep deterministic ordering.
176    ///
177    /// Idempotent on `(capsule_id, observation_id)`: re-attaching the same
178    /// observation to the same capsule is a no-op (`INSERT OR IGNORE` against
179    /// the composite primary key), so replaying a deduplicated observation
180    /// cannot error or create a duplicate link / a gap in `seq`.
181    ///
182    /// Concurrency note: the `SELECT MAX(seq)` then `INSERT` pair is NOT a
183    /// single atomic statement. It is only race-free because every caller is
184    /// serialized by the per-project service mutex (one writer at a time, the
185    /// daemon's single-writer rule). If that serialization is ever loosened
186    /// (finer-grained locking, a connection pool, direct concurrent store
187    /// access), two concurrent attaches to the same capsule could read the same
188    /// `MAX(seq)` and collide — wrap this read+write in a transaction (or use a
189    /// single `INSERT ... SELECT`) before relaxing the mutex granularity.
190    pub fn attach_observation_to_capsule(
191        &self,
192        capsule_id: &str,
193        observation_id: &str,
194    ) -> StoreResult<()> {
195        let next_seq: i64 = self.conn.query_row(
196            "SELECT COALESCE(MAX(seq), -1) + 1 FROM capsule_observations WHERE capsule_id = ?1",
197            [capsule_id],
198            |row| row.get(0),
199        )?;
200        self.conn.execute(
201            "INSERT OR IGNORE INTO capsule_observations (capsule_id, observation_id, seq)
202             VALUES (?1, ?2, ?3)",
203            (capsule_id, observation_id, next_seq),
204        )?;
205        Ok(())
206    }
207
208    /// Insert a summary. FTS sync happens automatically via triggers.
209    pub fn insert_summary(&self, summary: &Summary) -> StoreResult<()> {
210        self.conn.execute(
211            "INSERT INTO summaries (
212               id, capsule_id, content, confidence, created_at, evidence_refs
213             ) VALUES (:id, :capsule_id, :content, :confidence, :created_at, :evidence_refs)",
214            named_params! {
215                ":id": summary.id,
216                ":capsule_id": summary.capsule_id,
217                ":content": summary.content,
218                ":confidence": summary.confidence,
219                ":created_at": summary.created_at,
220                ":evidence_refs": serde_json::to_string(&summary.evidence_refs)?,
221            },
222        )?;
223        Ok(())
224    }
225
226    /// Insert a pin.
227    pub fn insert_pin(&self, pin: &Pin) -> StoreResult<()> {
228        self.conn.execute(
229            "INSERT INTO pins (
230               id, target_type, target_id, reason, created_at, expires_at, scope_ids,
231               session_id, repo_id, agent_id, user_id
232             ) VALUES (
233               :id, :target_type, :target_id, :reason, :created_at, :expires_at, :scope_ids,
234               :session_id, :repo_id, :agent_id, :user_id
235             )",
236            named_params! {
237                ":id": pin.id,
238                ":target_type": pin_target_type_to_str(pin.target_type),
239                ":target_id": pin.target_id,
240                ":reason": pin.reason,
241                ":created_at": pin.created_at,
242                ":expires_at": pin.expires_at,
243                ":scope_ids": serde_json::to_string(&pin.scope_ids)?,
244                ":session_id": pin.scope_ids.session_id,
245                ":repo_id": pin.scope_ids.repo_id,
246                ":agent_id": pin.scope_ids.agent_id,
247                ":user_id": pin.scope_ids.user_id,
248            },
249        )?;
250        Ok(())
251    }
252
253    /// Delete a pin. Errors if the pin does not exist.
254    pub fn delete_pin(&self, pin_id: &str) -> StoreResult<()> {
255        let changes = self
256            .conn
257            .execute("DELETE FROM pins WHERE id = ?1", [pin_id])?;
258        if changes == 0 {
259            return Err(StoreError::PinNotFound(pin_id.to_string()));
260        }
261        Ok(())
262    }
263
264    /// Active (non-expired) pins, optionally filtered by scope, newest first.
265    /// `now` defaults to the current time.
266    pub fn list_active_pins(
267        &self,
268        scope: Option<&ScopeIds>,
269        now: Option<Timestamp>,
270    ) -> StoreResult<Vec<Pin>> {
271        let mut sql = String::from(
272            "SELECT id, target_type, target_id, reason, created_at, expires_at, scope_ids
273             FROM pins
274             WHERE (expires_at IS NULL OR expires_at > ?)",
275        );
276        let mut params: Vec<SqlValue> = vec![SqlValue::Integer(now.unwrap_or_else(now_ms))];
277        if let Some(scope) = scope {
278            push_scope_filters(&mut sql, &mut params, scope);
279        }
280        sql.push_str(" ORDER BY created_at DESC");
281
282        let mut stmt = self.conn.prepare(&sql)?;
283        let rows = stmt.query_map(params_from_iter(params), |row| {
284            Ok(RawPin {
285                id: row.get(0)?,
286                target_type: row.get(1)?,
287                target_id: row.get(2)?,
288                reason: row.get(3)?,
289                created_at: row.get(4)?,
290                expires_at: row.get(5)?,
291                scope_ids: row.get(6)?,
292            })
293        })?;
294
295        rows.map(|row| row.map_err(StoreError::from).and_then(RawPin::into_pin))
296            .collect()
297    }
298
299    /// Redact an observation: replace content with `[redacted]` and set the
300    /// redacted flag. The FTS update trigger drops it from the index.
301    pub fn redact_observation(&self, observation_id: &str) -> StoreResult<()> {
302        let changes = self.conn.execute(
303            "UPDATE observations SET content = '[redacted]', redacted = 1 WHERE id = ?1",
304            [observation_id],
305        )?;
306        if changes == 0 {
307            return Err(StoreError::ObservationNotFound(observation_id.to_string()));
308        }
309        Ok(())
310    }
311
312    /// Run `f` inside a transaction: commits on `Ok`, rolls back on `Err`.
313    pub fn transaction<T>(&self, f: impl FnOnce(&Self) -> StoreResult<T>) -> StoreResult<T> {
314        let tx = self.conn.unchecked_transaction()?;
315        match f(self) {
316            Ok(value) => {
317                tx.commit()?;
318                Ok(value)
319            }
320            Err(err) => Err(err), // tx dropped here → rollback
321        }
322    }
323
324    // ===== READ PATH =====
325
326    /// Most recently opened capsule with status `open` for a session.
327    pub fn get_open_capsule_for_session(&self, session_id: &str) -> StoreResult<Option<Capsule>> {
328        let raw = self
329            .conn
330            .query_row(
331                "SELECT id, type, intent, status, opened_at, closed_at, scope_ids
332                 FROM capsules
333                 WHERE status = 'open' AND session_id = ?1
334                 ORDER BY opened_at DESC
335                 LIMIT 1",
336                [session_id],
337                RawCapsule::from_row,
338            )
339            .optional()?;
340        match raw {
341            None => Ok(None),
342            Some(raw) => Ok(Some(self.hydrate_capsule(raw)?)),
343        }
344    }
345
346    /// Capsule by ID, with its ordered observation IDs.
347    pub fn get_capsule(&self, capsule_id: &str) -> StoreResult<Option<Capsule>> {
348        let raw = self
349            .conn
350            .query_row(
351                "SELECT id, type, intent, status, opened_at, closed_at, scope_ids
352                 FROM capsules
353                 WHERE id = ?1",
354                [capsule_id],
355                RawCapsule::from_row,
356            )
357            .optional()?;
358        match raw {
359            None => Ok(None),
360            Some(raw) => Ok(Some(self.hydrate_capsule(raw)?)),
361        }
362    }
363
364    /// Latest summary for a capsule, if any.
365    pub fn get_latest_summary_for_capsule(&self, capsule_id: &str) -> StoreResult<Option<Summary>> {
366        self.conn
367            .query_row(
368                "SELECT id, capsule_id, content, confidence, created_at, evidence_refs
369                 FROM summaries
370                 WHERE capsule_id = ?1
371                 ORDER BY created_at DESC
372                 LIMIT 1",
373                [capsule_id],
374                RawSummary::from_row,
375            )
376            .optional()?
377            .map(RawSummary::into_summary)
378            .transpose()
379    }
380
381    /// Latest summary across every capsule in a scope, newest `created_at`
382    /// first. Joins `summaries` to `capsules` and filters by the denormalized
383    /// scope columns (the same set [`push_scope_filters`] understands).
384    ///
385    /// Ports the PreCompact query in
386    /// `plugins/kindling-claude-code/hooks/pre-compact.js`:
387    ///
388    /// ```sql
389    /// SELECT s.content, s.confidence FROM summaries s
390    ///   JOIN capsules c ON s.capsule_id = c.id
391    ///   WHERE c.repo_id = ?
392    ///   ORDER BY s.created_at DESC LIMIT 1
393    /// ```
394    ///
395    /// The Node hook filters on `repo_id` only; this method accepts a full
396    /// [`ScopeIds`] and applies a filter for each dimension that is set (so a
397    /// repo-only scope reproduces the Node behaviour exactly).
398    pub fn latest_summary_for_scope(
399        &self,
400        scope: Option<&ScopeIds>,
401    ) -> StoreResult<Option<Summary>> {
402        let mut sql = String::from(
403            "SELECT s.id, s.capsule_id, s.content, s.confidence, s.created_at, s.evidence_refs
404             FROM summaries s
405             JOIN capsules c ON s.capsule_id = c.id
406             WHERE 1 = 1",
407        );
408        let mut params: Vec<SqlValue> = Vec::new();
409        if let Some(scope) = scope {
410            // Scope columns live on `capsules`; qualify them to avoid ambiguity.
411            push_scope_filters_prefixed(&mut sql, &mut params, scope, "c.");
412        }
413        sql.push_str(" ORDER BY s.created_at DESC LIMIT 1");
414
415        let mut stmt = self.conn.prepare(&sql)?;
416        stmt.query_row(params_from_iter(params), RawSummary::from_row)
417            .optional()?
418            .map(RawSummary::into_summary)
419            .transpose()
420    }
421
422    /// Summary by ID.
423    pub fn get_summary_by_id(&self, summary_id: &str) -> StoreResult<Option<Summary>> {
424        self.conn
425            .query_row(
426                "SELECT id, capsule_id, content, confidence, created_at, evidence_refs
427                 FROM summaries
428                 WHERE id = ?1",
429                [summary_id],
430                RawSummary::from_row,
431            )
432            .optional()?
433            .map(RawSummary::into_summary)
434            .transpose()
435    }
436
437    /// Observation by ID.
438    pub fn get_observation_by_id(&self, observation_id: &str) -> StoreResult<Option<Observation>> {
439        self.conn
440            .query_row(
441                "SELECT id, kind, content, provenance, ts, scope_ids, redacted
442                 FROM observations
443                 WHERE id = ?1",
444                [observation_id],
445                RawObservation::from_row,
446            )
447            .optional()?
448            .map(RawObservation::into_observation)
449            .transpose()
450    }
451
452    /// Non-redacted observations filtered by scope and time range, newest
453    /// first, capped at `limit` (TS default is 100).
454    pub fn query_observations(
455        &self,
456        scope: Option<&ScopeIds>,
457        from_ts: Option<Timestamp>,
458        to_ts: Option<Timestamp>,
459        limit: u32,
460    ) -> StoreResult<Vec<Observation>> {
461        let mut sql = String::from(
462            "SELECT id, kind, content, provenance, ts, scope_ids, redacted
463             FROM observations
464             WHERE redacted = 0",
465        );
466        let mut params: Vec<SqlValue> = Vec::new();
467        if let Some(scope) = scope {
468            push_scope_filters(&mut sql, &mut params, scope);
469        }
470        if let Some(from_ts) = from_ts {
471            sql.push_str(" AND ts >= ?");
472            params.push(SqlValue::Integer(from_ts));
473        }
474        if let Some(to_ts) = to_ts {
475            sql.push_str(" AND ts <= ?");
476            params.push(SqlValue::Integer(to_ts));
477        }
478        sql.push_str(" ORDER BY ts DESC LIMIT ?");
479        params.push(SqlValue::Integer(i64::from(limit)));
480
481        let mut stmt = self.conn.prepare(&sql)?;
482        let rows = stmt.query_map(params_from_iter(params), RawObservation::from_row)?;
483        rows.map(|row| {
484            row.map_err(StoreError::from)
485                .and_then(RawObservation::into_observation)
486        })
487        .collect()
488    }
489
490    /// Exhaustive, deterministically-paginated observation list.
491    ///
492    /// Unlike [`Self::query_observations`] (ranked-adjacent, `ts DESC`), this
493    /// enumerates the **full** matching set in the stable `(ts ASC, id ASC)`
494    /// order via a keyset cursor — the only scheme that is complete under
495    /// concurrent appends (a row inserted after the cursor passed its position is
496    /// picked up on a later page, never skipped or duplicated by an offset shift).
497    ///
498    /// Filters: `scope` (denormalized columns), `kinds` (empty = all), and a
499    /// **half-open** `[since, until)` window on `ts`. `cursor` is the
500    /// `(ts, id)` of the last row of the previous page; rows strictly greater are
501    /// returned. Redacted rows are excluded unless `include_redacted`. Returns up
502    /// to `limit` rows; the caller fetches `limit + 1` to detect a next page.
503    #[allow(clippy::too_many_arguments)]
504    pub fn list_observations(
505        &self,
506        scope: Option<&ScopeIds>,
507        kinds: &[ObservationKind],
508        since: Option<Timestamp>,
509        until: Option<Timestamp>,
510        cursor: Option<(Timestamp, &str)>,
511        include_redacted: bool,
512        limit: u32,
513    ) -> StoreResult<Vec<Observation>> {
514        let mut sql = String::from(
515            "SELECT id, kind, content, provenance, ts, scope_ids, redacted
516             FROM observations
517             WHERE 1 = 1",
518        );
519        let mut params: Vec<SqlValue> = Vec::new();
520        if let Some(scope) = scope {
521            push_scope_filters(&mut sql, &mut params, scope);
522        }
523        if !include_redacted {
524            sql.push_str(" AND redacted = 0");
525        }
526        if !kinds.is_empty() {
527            sql.push_str(" AND kind IN (");
528            for (i, kind) in kinds.iter().enumerate() {
529                if i > 0 {
530                    sql.push(',');
531                }
532                sql.push('?');
533                params.push(SqlValue::Text(observation_kind_to_str(*kind).to_string()));
534            }
535            sql.push(')');
536        }
537        if let Some(since) = since {
538            sql.push_str(" AND ts >= ?");
539            params.push(SqlValue::Integer(since));
540        }
541        if let Some(until) = until {
542            sql.push_str(" AND ts < ?");
543            params.push(SqlValue::Integer(until));
544        }
545        if let Some((cur_ts, cur_id)) = cursor {
546            sql.push_str(" AND (ts > ? OR (ts = ? AND id > ?))");
547            params.push(SqlValue::Integer(cur_ts));
548            params.push(SqlValue::Integer(cur_ts));
549            params.push(SqlValue::Text(cur_id.to_string()));
550        }
551        sql.push_str(" ORDER BY ts ASC, id ASC LIMIT ?");
552        params.push(SqlValue::Integer(i64::from(limit)));
553
554        let mut stmt = self.conn.prepare(&sql)?;
555        let rows = stmt.query_map(params_from_iter(params), RawObservation::from_row)?;
556        rows.map(|row| {
557            row.map_err(StoreError::from)
558                .and_then(RawObservation::into_observation)
559        })
560        .collect()
561    }
562
563    /// Evidence snippets for the given observation IDs, truncated to
564    /// `max_chars` characters (with `...` appended when truncated). Preserves
565    /// input order; silently skips IDs that do not exist.
566    pub fn get_evidence_snippets(
567        &self,
568        observation_ids: &[String],
569        max_chars: usize,
570    ) -> StoreResult<Vec<EvidenceSnippet>> {
571        if observation_ids.is_empty() {
572            return Ok(Vec::new());
573        }
574
575        let placeholders = vec!["?"; observation_ids.len()].join(",");
576        let sql =
577            format!("SELECT id, kind, content FROM observations WHERE id IN ({placeholders})");
578        let mut stmt = self.conn.prepare(&sql)?;
579        let rows = stmt.query_map(params_from_iter(observation_ids), |row| {
580            Ok((
581                row.get::<_, String>(0)?,
582                row.get::<_, String>(1)?,
583                row.get::<_, String>(2)?,
584            ))
585        })?;
586
587        let mut by_id = std::collections::HashMap::new();
588        for row in rows {
589            let (id, kind, content) = row?;
590            by_id.insert(id, (kind, content));
591        }
592
593        observation_ids
594            .iter()
595            .filter_map(|id| by_id.remove(id).map(|found| (id, found)))
596            .map(|(id, (kind, content))| {
597                Ok(EvidenceSnippet {
598                    observation_id: id.clone(),
599                    kind: observation_kind_from_str(&kind)?,
600                    snippet: truncate_snippet(&content, max_chars),
601                })
602            })
603            .collect()
604    }
605
606    // ===== export / import / stats =====
607    //
608    // These back the CLI `export`, `import`, and `status` verbs (PORT-012) and
609    // mirror the deterministic ordering of
610    // `packages/kindling-store-sqlite/src/store/export.ts` so a Rust export
611    // round-trips byte-compatibly with the TS importer (and vice versa).
612
613    /// All non-redacted observations (or all, when `include_redacted`),
614    /// optionally scoped, ordered `ts ASC, id ASC`. Matches the TS
615    /// `exportDatabase` observation query.
616    pub fn export_observations(
617        &self,
618        scope: Option<&ScopeIds>,
619        include_redacted: bool,
620        limit: Option<u32>,
621    ) -> StoreResult<Vec<Observation>> {
622        let mut sql = String::from(
623            "SELECT id, kind, content, provenance, ts, scope_ids, redacted
624             FROM observations
625             WHERE 1 = 1",
626        );
627        let mut params: Vec<SqlValue> = Vec::new();
628        if let Some(scope) = scope {
629            push_scope_filters(&mut sql, &mut params, scope);
630        }
631        if !include_redacted {
632            sql.push_str(" AND redacted = 0");
633        }
634        sql.push_str(" ORDER BY ts ASC, id ASC");
635        if let Some(limit) = limit {
636            sql.push_str(" LIMIT ?");
637            params.push(SqlValue::Integer(i64::from(limit)));
638        }
639
640        let mut stmt = self.conn.prepare(&sql)?;
641        let rows = stmt.query_map(params_from_iter(params), RawObservation::from_row)?;
642        rows.map(|row| {
643            row.map_err(StoreError::from)
644                .and_then(RawObservation::into_observation)
645        })
646        .collect()
647    }
648
649    /// All capsules (with ordered observation ids), optionally scoped, ordered
650    /// `opened_at ASC, id ASC`. Matches the TS `exportDatabase` capsule query.
651    pub fn export_capsules(&self, scope: Option<&ScopeIds>) -> StoreResult<Vec<Capsule>> {
652        let mut sql = String::from(
653            "SELECT id, type, intent, status, opened_at, closed_at, scope_ids
654             FROM capsules
655             WHERE 1 = 1",
656        );
657        let mut params: Vec<SqlValue> = Vec::new();
658        if let Some(scope) = scope {
659            push_scope_filters(&mut sql, &mut params, scope);
660        }
661        sql.push_str(" ORDER BY opened_at ASC, id ASC");
662
663        let mut stmt = self.conn.prepare(&sql)?;
664        let raws = stmt
665            .query_map(params_from_iter(params), RawCapsule::from_row)?
666            .collect::<Result<Vec<_>, _>>()?;
667        raws.into_iter()
668            .map(|raw| self.hydrate_capsule(raw))
669            .collect()
670    }
671
672    /// All summaries whose capsule matches `scope`, ordered
673    /// `created_at ASC, id ASC`. Matches the TS `exportDatabase` summary query
674    /// (joins `summaries` to `capsules` and filters on the capsule scope).
675    pub fn export_summaries(&self, scope: Option<&ScopeIds>) -> StoreResult<Vec<Summary>> {
676        let mut sql = String::from(
677            "SELECT s.id, s.capsule_id, s.content, s.confidence, s.created_at, s.evidence_refs
678             FROM summaries s
679             JOIN capsules c ON s.capsule_id = c.id
680             WHERE 1 = 1",
681        );
682        let mut params: Vec<SqlValue> = Vec::new();
683        if let Some(scope) = scope {
684            push_scope_filters_prefixed(&mut sql, &mut params, scope, "c.");
685        }
686        sql.push_str(" ORDER BY s.created_at ASC, s.id ASC");
687
688        let mut stmt = self.conn.prepare(&sql)?;
689        let rows = stmt.query_map(params_from_iter(params), RawSummary::from_row)?;
690        rows.map(|row| {
691            row.map_err(StoreError::from)
692                .and_then(RawSummary::into_summary)
693        })
694        .collect()
695    }
696
697    /// All pins (including expired — export is a full snapshot), optionally
698    /// scoped, ordered `created_at ASC, id ASC`. Matches the TS `exportDatabase`
699    /// pin query.
700    pub fn export_pins(&self, scope: Option<&ScopeIds>) -> StoreResult<Vec<Pin>> {
701        let mut sql = String::from(
702            "SELECT id, target_type, target_id, reason, created_at, expires_at, scope_ids
703             FROM pins
704             WHERE 1 = 1",
705        );
706        let mut params: Vec<SqlValue> = Vec::new();
707        if let Some(scope) = scope {
708            push_scope_filters(&mut sql, &mut params, scope);
709        }
710        sql.push_str(" ORDER BY created_at ASC, id ASC");
711
712        let mut stmt = self.conn.prepare(&sql)?;
713        let rows = stmt.query_map(params_from_iter(params), |row| {
714            Ok(RawPin {
715                id: row.get(0)?,
716                target_type: row.get(1)?,
717                target_id: row.get(2)?,
718                reason: row.get(3)?,
719                created_at: row.get(4)?,
720                expires_at: row.get(5)?,
721                scope_ids: row.get(6)?,
722            })
723        })?;
724        rows.map(|row| row.map_err(StoreError::from).and_then(RawPin::into_pin))
725            .collect()
726    }
727
728    /// Insert an observation if its id is not already present; returns whether a
729    /// row was written. Mirrors the TS importer's `INSERT OR IGNORE`.
730    pub fn import_observation(&self, observation: &Observation) -> StoreResult<bool> {
731        let changes = self.conn.execute(
732            "INSERT OR IGNORE INTO observations (
733               id, kind, content, provenance, ts, scope_ids, redacted,
734               session_id, repo_id, agent_id, user_id
735             ) VALUES (
736               :id, :kind, :content, :provenance, :ts, :scope_ids, :redacted,
737               :session_id, :repo_id, :agent_id, :user_id
738             )",
739            named_params! {
740                ":id": observation.id,
741                ":kind": observation_kind_to_str(observation.kind),
742                ":content": observation.content,
743                ":provenance": serde_json::to_string(&observation.provenance)?,
744                ":ts": observation.ts,
745                ":scope_ids": serde_json::to_string(&observation.scope_ids)?,
746                ":redacted": observation.redacted,
747                ":session_id": observation.scope_ids.session_id,
748                ":repo_id": observation.scope_ids.repo_id,
749                ":agent_id": observation.scope_ids.agent_id,
750                ":user_id": observation.scope_ids.user_id,
751            },
752        )?;
753        Ok(changes > 0)
754    }
755
756    /// Insert a capsule if absent (and, when written, its ordered
757    /// `capsule_observations` links). Returns whether the capsule row was
758    /// written. Mirrors the TS importer.
759    pub fn import_capsule(&self, capsule: &Capsule) -> StoreResult<bool> {
760        let changes = self.conn.execute(
761            "INSERT OR IGNORE INTO capsules (
762               id, type, intent, status, opened_at, closed_at, scope_ids,
763               session_id, repo_id, agent_id, user_id
764             ) VALUES (
765               :id, :type, :intent, :status, :opened_at, :closed_at, :scope_ids,
766               :session_id, :repo_id, :agent_id, :user_id
767             )",
768            named_params! {
769                ":id": capsule.id,
770                ":type": capsule_type_to_str(capsule.kind),
771                ":intent": capsule.intent,
772                ":status": capsule_status_to_str(capsule.status),
773                ":opened_at": capsule.opened_at,
774                ":closed_at": capsule.closed_at,
775                ":scope_ids": serde_json::to_string(&capsule.scope_ids)?,
776                ":session_id": capsule.scope_ids.session_id,
777                ":repo_id": capsule.scope_ids.repo_id,
778                ":agent_id": capsule.scope_ids.agent_id,
779                ":user_id": capsule.scope_ids.user_id,
780            },
781        )?;
782        if changes == 0 {
783            return Ok(false);
784        }
785        let mut stmt = self.conn.prepare(
786            "INSERT OR IGNORE INTO capsule_observations (capsule_id, observation_id, seq)
787             VALUES (?1, ?2, ?3)",
788        )?;
789        for (seq, obs_id) in capsule.observation_ids.iter().enumerate() {
790            stmt.execute((&capsule.id, obs_id, seq as i64))?;
791        }
792        Ok(true)
793    }
794
795    /// Insert a summary if its id is absent; returns whether a row was written.
796    pub fn import_summary(&self, summary: &Summary) -> StoreResult<bool> {
797        let changes = self.conn.execute(
798            "INSERT OR IGNORE INTO summaries (
799               id, capsule_id, content, confidence, created_at, evidence_refs
800             ) VALUES (:id, :capsule_id, :content, :confidence, :created_at, :evidence_refs)",
801            named_params! {
802                ":id": summary.id,
803                ":capsule_id": summary.capsule_id,
804                ":content": summary.content,
805                ":confidence": summary.confidence,
806                ":created_at": summary.created_at,
807                ":evidence_refs": serde_json::to_string(&summary.evidence_refs)?,
808            },
809        )?;
810        Ok(changes > 0)
811    }
812
813    /// Insert a pin if its id is absent; returns whether a row was written.
814    pub fn import_pin(&self, pin: &Pin) -> StoreResult<bool> {
815        let changes = self.conn.execute(
816            "INSERT OR IGNORE INTO pins (
817               id, target_type, target_id, reason, created_at, expires_at, scope_ids,
818               session_id, repo_id, agent_id, user_id
819             ) VALUES (
820               :id, :target_type, :target_id, :reason, :created_at, :expires_at, :scope_ids,
821               :session_id, :repo_id, :agent_id, :user_id
822             )",
823            named_params! {
824                ":id": pin.id,
825                ":target_type": pin_target_type_to_str(pin.target_type),
826                ":target_id": pin.target_id,
827                ":reason": pin.reason,
828                ":created_at": pin.created_at,
829                ":expires_at": pin.expires_at,
830                ":scope_ids": serde_json::to_string(&pin.scope_ids)?,
831                ":session_id": pin.scope_ids.session_id,
832                ":repo_id": pin.scope_ids.repo_id,
833                ":agent_id": pin.scope_ids.agent_id,
834                ":user_id": pin.scope_ids.user_id,
835            },
836        )?;
837        Ok(changes > 0)
838    }
839
840    /// Aggregate counts + database size for the `status` verb. Mirrors the
841    /// pragmas + `COUNT(*)` queries in
842    /// `packages/kindling-cli/src/commands/status.ts`.
843    pub fn database_stats(&self) -> StoreResult<DatabaseStats> {
844        let count =
845            |sql: &str| -> StoreResult<i64> { Ok(self.conn.query_row(sql, [], |row| row.get(0))?) };
846        let observations = count("SELECT COUNT(*) FROM observations")?;
847        let capsules = count("SELECT COUNT(*) FROM capsules")?;
848        let summaries = count("SELECT COUNT(*) FROM summaries")?;
849        let pins = count("SELECT COUNT(*) FROM pins")?;
850        let redacted = count("SELECT COUNT(*) FROM observations WHERE redacted = 1")?;
851        let open_capsules = count("SELECT COUNT(*) FROM capsules WHERE status = 'open'")?;
852        let latest_ts: Option<i64> = self
853            .conn
854            .query_row("SELECT MAX(ts) FROM observations", [], |row| row.get(0))
855            .optional()?
856            .flatten();
857
858        let page_count: i64 = self
859            .conn
860            .query_row("PRAGMA page_count", [], |row| row.get(0))?;
861        let page_size: i64 = self
862            .conn
863            .query_row("PRAGMA page_size", [], |row| row.get(0))?;
864
865        Ok(DatabaseStats {
866            observations,
867            capsules,
868            summaries,
869            pins,
870            redacted,
871            open_capsules,
872            latest_ts,
873            size_bytes: page_count * page_size,
874        })
875    }
876
877    fn hydrate_capsule(&self, raw: RawCapsule) -> StoreResult<Capsule> {
878        let mut stmt = self.conn.prepare(
879            "SELECT observation_id FROM capsule_observations WHERE capsule_id = ?1 ORDER BY seq",
880        )?;
881        let observation_ids = stmt
882            .query_map([&raw.id], |row| row.get::<_, String>(0))?
883            .collect::<Result<Vec<_>, _>>()?;
884        raw.into_capsule(observation_ids)
885    }
886}
887
888/// Aggregate database statistics returned by
889/// [`SqliteKindlingStore::database_stats`].
890#[derive(Debug, Clone, PartialEq, Eq)]
891pub struct DatabaseStats {
892    pub observations: i64,
893    pub capsules: i64,
894    pub summaries: i64,
895    pub pins: i64,
896    pub redacted: i64,
897    pub open_capsules: i64,
898    /// Newest observation timestamp, or `None` when there are no observations.
899    pub latest_ts: Option<Timestamp>,
900    /// `page_count * page_size`.
901    pub size_bytes: i64,
902}
903
904fn now_ms() -> Timestamp {
905    SystemTime::now()
906        .duration_since(UNIX_EPOCH)
907        .expect("system clock before Unix epoch")
908        .as_millis() as Timestamp
909}
910
911/// Truncate to `max_chars` characters, appending `...` when content was cut.
912/// Counts `char`s (the TS implementation counts UTF-16 code units; the two
913/// agree for everything below the astral planes).
914fn truncate_snippet(content: &str, max_chars: usize) -> String {
915    match content.char_indices().nth(max_chars) {
916        None => content.to_string(),
917        Some((byte_idx, _)) => format!("{}...", &content[..byte_idx]),
918    }
919}
920
921/// Append `AND <column> = ?` filters for each scope dimension that is set.
922/// `task_id` has no denormalized column and is intentionally not filterable,
923/// matching the TS store.
924fn push_scope_filters(sql: &mut String, params: &mut Vec<SqlValue>, scope: &ScopeIds) {
925    push_scope_filters_prefixed(sql, params, scope, "");
926}
927
928/// [`push_scope_filters`] with a column prefix (e.g. `"c."`) so the same scope
929/// filter can target a specific table in a join.
930fn push_scope_filters_prefixed(
931    sql: &mut String,
932    params: &mut Vec<SqlValue>,
933    scope: &ScopeIds,
934    prefix: &str,
935) {
936    let filters = [
937        ("session_id", &scope.session_id),
938        ("repo_id", &scope.repo_id),
939        ("agent_id", &scope.agent_id),
940        ("user_id", &scope.user_id),
941    ];
942    for (column, value) in filters {
943        if let Some(value) = value {
944            sql.push_str(" AND ");
945            sql.push_str(prefix);
946            sql.push_str(column);
947            sql.push_str(" = ?");
948            params.push(SqlValue::Text(value.clone()));
949        }
950    }
951}
952
953// ===== enum <-> TEXT column mappings =====
954// Exhaustive matches so adding a variant in kindling-types fails compilation
955// here until the mapping (and the schema CHECK constraint) is updated.
956
957fn observation_kind_to_str(kind: ObservationKind) -> &'static str {
958    match kind {
959        ObservationKind::ToolCall => "tool_call",
960        ObservationKind::Command => "command",
961        ObservationKind::FileDiff => "file_diff",
962        ObservationKind::Error => "error",
963        ObservationKind::Message => "message",
964        ObservationKind::NodeStart => "node_start",
965        ObservationKind::NodeEnd => "node_end",
966        ObservationKind::NodeOutput => "node_output",
967        ObservationKind::NodeError => "node_error",
968    }
969}
970
971fn observation_kind_from_str(value: &str) -> StoreResult<ObservationKind> {
972    Ok(match value {
973        "tool_call" => ObservationKind::ToolCall,
974        "command" => ObservationKind::Command,
975        "file_diff" => ObservationKind::FileDiff,
976        "error" => ObservationKind::Error,
977        "message" => ObservationKind::Message,
978        "node_start" => ObservationKind::NodeStart,
979        "node_end" => ObservationKind::NodeEnd,
980        "node_output" => ObservationKind::NodeOutput,
981        "node_error" => ObservationKind::NodeError,
982        other => {
983            return Err(StoreError::UnexpectedRowValue {
984                column: "observations.kind",
985                value: other.to_string(),
986            })
987        }
988    })
989}
990
991fn capsule_type_to_str(kind: CapsuleType) -> &'static str {
992    match kind {
993        CapsuleType::Session => "session",
994        CapsuleType::PocketflowNode => "pocketflow_node",
995    }
996}
997
998fn capsule_type_from_str(value: &str) -> StoreResult<CapsuleType> {
999    Ok(match value {
1000        "session" => CapsuleType::Session,
1001        "pocketflow_node" => CapsuleType::PocketflowNode,
1002        other => {
1003            return Err(StoreError::UnexpectedRowValue {
1004                column: "capsules.type",
1005                value: other.to_string(),
1006            })
1007        }
1008    })
1009}
1010
1011fn capsule_status_to_str(status: CapsuleStatus) -> &'static str {
1012    match status {
1013        CapsuleStatus::Open => "open",
1014        CapsuleStatus::Closed => "closed",
1015    }
1016}
1017
1018fn capsule_status_from_str(value: &str) -> StoreResult<CapsuleStatus> {
1019    Ok(match value {
1020        "open" => CapsuleStatus::Open,
1021        "closed" => CapsuleStatus::Closed,
1022        other => {
1023            return Err(StoreError::UnexpectedRowValue {
1024                column: "capsules.status",
1025                value: other.to_string(),
1026            })
1027        }
1028    })
1029}
1030
1031fn pin_target_type_to_str(target: PinTargetType) -> &'static str {
1032    match target {
1033        PinTargetType::Observation => "observation",
1034        PinTargetType::Summary => "summary",
1035    }
1036}
1037
1038fn pin_target_type_from_str(value: &str) -> StoreResult<PinTargetType> {
1039    Ok(match value {
1040        "observation" => PinTargetType::Observation,
1041        "summary" => PinTargetType::Summary,
1042        other => {
1043            return Err(StoreError::UnexpectedRowValue {
1044                column: "pins.target_type",
1045                value: other.to_string(),
1046            })
1047        }
1048    })
1049}
1050
1051// ===== raw row intermediates =====
1052// Row closures may only return rusqlite errors, so rows are read into
1053// SQL-native shapes first and converted (JSON parsing, enum mapping) outside.
1054
1055struct RawObservation {
1056    id: String,
1057    kind: String,
1058    content: String,
1059    provenance: String,
1060    ts: i64,
1061    scope_ids: String,
1062    redacted: i64,
1063}
1064
1065impl RawObservation {
1066    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1067        Ok(Self {
1068            id: row.get(0)?,
1069            kind: row.get(1)?,
1070            content: row.get(2)?,
1071            provenance: row.get(3)?,
1072            ts: row.get(4)?,
1073            scope_ids: row.get(5)?,
1074            redacted: row.get(6)?,
1075        })
1076    }
1077
1078    fn into_observation(self) -> StoreResult<Observation> {
1079        Ok(Observation {
1080            id: self.id,
1081            kind: observation_kind_from_str(&self.kind)?,
1082            content: self.content,
1083            provenance: serde_json::from_str(&self.provenance)?,
1084            ts: self.ts,
1085            scope_ids: serde_json::from_str(&self.scope_ids)?,
1086            redacted: self.redacted == 1,
1087        })
1088    }
1089}
1090
1091struct RawCapsule {
1092    id: String,
1093    kind: String,
1094    intent: String,
1095    status: String,
1096    opened_at: i64,
1097    closed_at: Option<i64>,
1098    scope_ids: String,
1099}
1100
1101impl RawCapsule {
1102    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1103        Ok(Self {
1104            id: row.get(0)?,
1105            kind: row.get(1)?,
1106            intent: row.get(2)?,
1107            status: row.get(3)?,
1108            opened_at: row.get(4)?,
1109            closed_at: row.get(5)?,
1110            scope_ids: row.get(6)?,
1111        })
1112    }
1113
1114    fn into_capsule(self, observation_ids: Vec<String>) -> StoreResult<Capsule> {
1115        Ok(Capsule {
1116            id: self.id,
1117            kind: capsule_type_from_str(&self.kind)?,
1118            intent: self.intent,
1119            status: capsule_status_from_str(&self.status)?,
1120            opened_at: self.opened_at,
1121            closed_at: self.closed_at,
1122            scope_ids: serde_json::from_str(&self.scope_ids)?,
1123            observation_ids,
1124            // The capsules table has no summary_id column; the relationship
1125            // lives in summaries.capsule_id (matches the TS store).
1126            summary_id: None,
1127        })
1128    }
1129}
1130
1131struct RawSummary {
1132    id: String,
1133    capsule_id: String,
1134    content: String,
1135    confidence: f64,
1136    created_at: i64,
1137    evidence_refs: String,
1138}
1139
1140impl RawSummary {
1141    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1142        Ok(Self {
1143            id: row.get(0)?,
1144            capsule_id: row.get(1)?,
1145            content: row.get(2)?,
1146            confidence: row.get(3)?,
1147            created_at: row.get(4)?,
1148            evidence_refs: row.get(5)?,
1149        })
1150    }
1151
1152    fn into_summary(self) -> StoreResult<Summary> {
1153        Ok(Summary {
1154            id: self.id,
1155            capsule_id: self.capsule_id,
1156            content: self.content,
1157            confidence: self.confidence,
1158            created_at: self.created_at,
1159            evidence_refs: serde_json::from_str(&self.evidence_refs)?,
1160        })
1161    }
1162}
1163
1164struct RawPin {
1165    id: String,
1166    target_type: String,
1167    target_id: String,
1168    reason: Option<String>,
1169    created_at: i64,
1170    expires_at: Option<i64>,
1171    scope_ids: String,
1172}
1173
1174impl RawPin {
1175    fn into_pin(self) -> StoreResult<Pin> {
1176        Ok(Pin {
1177            id: self.id,
1178            target_type: pin_target_type_from_str(&self.target_type)?,
1179            target_id: self.target_id,
1180            reason: self.reason,
1181            created_at: self.created_at,
1182            expires_at: self.expires_at,
1183            scope_ids: serde_json::from_str(&self.scope_ids)?,
1184        })
1185    }
1186}