Skip to main content

kernex_memory/
memory_store.rs

1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2
3//! Public trait surface for the SQLite-backed memory store.
4//!
5//! Mirrors the inherent method surface that downstream consumers
6//! (`kernex-runtime` composition, the sister-repo binary's REPL, future
7//! CLI/HTTP/MCP) call today, plus three new soft-delete methods on the
8//! `facts` table. Hard-delete inherent methods (`delete_fact`,
9//! `delete_facts`) stay on `Store` for emergency cleanup tooling and are
10//! deliberately NOT on the trait so the default consumer path uses
11//! recoverable soft-delete.
12//!
13//! `Runtime::store_handle()` returns `Arc<dyn MemoryStore>` so a binary
14//! consumer can share the runtime's composed `Store` instance instead of
15//! opening a second SQLite connection against the same database file.
16
17use std::sync::Arc;
18use std::time::SystemTime;
19
20use async_trait::async_trait;
21
22use crate::error::MemoryError;
23use crate::observation::{Observation, ObservationType, SaveEntry};
24use crate::store::{DueTask, Store, TaskRunRecord, UsageSummary};
25use crate::types::{HistoryRow, MessageRow};
26
27/// Public trait surface over [`Store`].
28///
29/// Returned from `kernex-runtime::Runtime::store_handle()` as
30/// `Arc<dyn MemoryStore>`. Consumers should prefer this trait over the
31/// concrete `Store` type so future schema changes do not ripple into call
32/// sites.
33#[async_trait]
34pub trait MemoryStore: Send + Sync {
35    // --- conversations / messages ---
36
37    /// Mark the active conversation for `(channel, sender_id, project)` as
38    /// closed. Returns `true` if a row transitioned from active to closed.
39    async fn close_current_conversation(
40        &self,
41        channel: &str,
42        sender_id: &str,
43        project: &str,
44    ) -> Result<bool, MemoryError>;
45
46    /// Aggregate counters:
47    /// `(conversation_count, message_count, observation_count, fact_count)`.
48    ///
49    /// **Breaking change (kernex-memory 0.8.0):** prior versions returned
50    /// a 3-tuple `(conversation, message, fact)`. The observation count
51    /// joins the tuple at position 2; consumers must destructure four
52    /// elements after the bump. Soft-deleted observations and facts are
53    /// excluded; conversations and messages have no soft-delete column
54    /// today and are counted whole.
55    async fn get_memory_stats(&self, sender_id: &str) -> Result<(i64, i64, i64, i64), MemoryError>;
56
57    /// On-disk byte size of the SQLite database file.
58    async fn db_size(&self) -> Result<u64, MemoryError>;
59
60    /// Aggregate token usage across all sessions.
61    async fn get_total_usage(&self) -> Result<UsageSummary, MemoryError>;
62
63    /// Recent closed-conversation summaries for a given channel + sender,
64    /// newest first, capped at `limit`.
65    async fn get_history(
66        &self,
67        channel: &str,
68        sender_id: &str,
69        limit: i64,
70    ) -> Result<Vec<HistoryRow>, MemoryError>;
71
72    /// FTS5 full-text search over user messages, excluding the live
73    /// conversation. When `since` is `Some`, only rows with
74    /// `timestamp >= since` are returned and `limit` applies after the
75    /// recency filter.
76    async fn search_messages(
77        &self,
78        query: &str,
79        exclude_conversation_id: &str,
80        sender_id: &str,
81        limit: i64,
82        since: Option<SystemTime>,
83    ) -> Result<Vec<MessageRow>, MemoryError>;
84
85    /// Fetch a single message row by its UUID. Returns `None` when the
86    /// id is missing.
87    async fn get_message_by_id(&self, id: &str) -> Result<Option<MessageRow>, MemoryError>;
88
89    // --- facts (write paths plus soft-only delete on the trait) ---
90
91    /// Upsert a fact for `(sender_id, key)`. If the row was previously
92    /// soft-deleted, this clears `deleted_at` so the value is visible
93    /// again to default-filtered reads.
94    async fn store_fact(&self, sender_id: &str, key: &str, value: &str) -> Result<(), MemoryError>;
95
96    /// Read a single active fact by `(sender_id, key)`. Returns `None` if
97    /// the row is soft-deleted, missing, or never existed.
98    async fn get_fact(&self, sender_id: &str, key: &str) -> Result<Option<String>, MemoryError>;
99
100    /// Active (not soft-deleted) facts for `sender_id`.
101    async fn get_facts(&self, sender_id: &str) -> Result<Vec<(String, String)>, MemoryError>;
102
103    /// Soft-delete a single fact by setting its `deleted_at` timestamp.
104    /// Returns `true` if a row transitioned from active to deleted; `false`
105    /// if the row was already deleted, missing, or never existed.
106    async fn soft_delete_fact(&self, sender_id: &str, key: &str) -> Result<bool, MemoryError>;
107
108    /// Soft-delete multiple facts. With `Some(key)`, soft-deletes that
109    /// specific key. With `None`, soft-deletes every active fact for the
110    /// sender. Returns the count of rows that transitioned from active to
111    /// deleted.
112    async fn soft_delete_facts(
113        &self,
114        sender_id: &str,
115        key: Option<&str>,
116    ) -> Result<u64, MemoryError>;
117
118    /// Read soft-deleted facts (debug / recovery helper). Returns
119    /// `(key, value, deleted_at)` rows for `sender_id`.
120    async fn list_soft_deleted_facts(
121        &self,
122        sender_id: &str,
123    ) -> Result<Vec<(String, String, String)>, MemoryError>;
124
125    // --- observations (typed write surface introduced in 0.8.0) ---
126
127    /// Persist a typed observation and return the saved row. Generates
128    /// a fresh UUIDv4 id; sets `created_at == updated_at == now`. The
129    /// DB enforces `length(title) > 0` and the seven-value `type` CHECK
130    /// constraint; violations surface as `MemoryError::Sqlite`.
131    async fn save_observation(&self, entry: SaveEntry) -> Result<Observation, MemoryError>;
132
133    /// Fetch an active observation by id. Returns `None` when the id is
134    /// missing OR the row is soft-deleted (CC-9 invariant). Mirrors the
135    /// `get_message_by_id` shape introduced in 0.7.0.
136    async fn get_observation_by_id(&self, id: &str) -> Result<Option<Observation>, MemoryError>;
137
138    /// FTS5 search across observation title + structured fields.
139    /// Optional `since` filters by `created_at >=`; optional `kind`
140    /// narrows to a single type. Soft-deleted rows never appear.
141    async fn search_observations(
142        &self,
143        query: &str,
144        sender_id: &str,
145        limit: i64,
146        since: Option<SystemTime>,
147        kind: Option<ObservationType>,
148    ) -> Result<Vec<Observation>, MemoryError>;
149
150    /// Soft-delete an observation by id. Returns `Ok(true)` on
151    /// transition from active to deleted; `Ok(false)` when the row was
152    /// already deleted, missing, or never existed (matches the
153    /// `soft_delete_fact` contract).
154    async fn soft_delete_observation(&self, id: &str) -> Result<bool, MemoryError>;
155
156    /// Read soft-deleted observations for a sender. Recovery helper;
157    /// surfaced on the trait so future tooling can offer an "undelete"
158    /// command without dropping back to the inherent `Store`.
159    async fn list_soft_deleted_observations(
160        &self,
161        sender_id: &str,
162    ) -> Result<Vec<Observation>, MemoryError>;
163
164    // --- scheduled tasks ---
165
166    /// Insert a new scheduled task. Returns the new task id.
167    #[allow(clippy::too_many_arguments)]
168    async fn create_task(
169        &self,
170        channel: &str,
171        sender_id: &str,
172        reply_target: &str,
173        description: &str,
174        due_at: &str,
175        repeat: Option<&str>,
176        task_type: &str,
177        project: &str,
178    ) -> Result<String, MemoryError>;
179
180    /// Pending tasks for `sender_id` as raw `(id, description, due_at,
181    /// repeat, task_type, project)` rows, ordered by `due_at` ascending.
182    async fn get_tasks_for_sender(
183        &self,
184        sender_id: &str,
185    ) -> Result<Vec<(String, String, String, Option<String>, String, String)>, MemoryError>;
186
187    /// Mark a task as completed. With `Some("daily")` / `Some("weekly")` /
188    /// etc., reschedules the next occurrence; with `None` or `Some("once")`,
189    /// the task transitions to a terminal status.
190    async fn complete_task(&self, id: &str, repeat: Option<&str>) -> Result<(), MemoryError>;
191
192    /// Record a task failure. Increments retry counter; transitions to a
193    /// terminal failed status when retries exhaust. Returns `true` if the
194    /// task transitioned to a terminal state.
195    async fn fail_task(&self, id: &str, error: &str, max_retries: u32)
196        -> Result<bool, MemoryError>;
197
198    /// Cancel a pending task whose id starts with `id_prefix`, scoped to
199    /// `sender_id`. Returns `true` if a row was cancelled.
200    async fn cancel_task(&self, id_prefix: &str, sender_id: &str) -> Result<bool, MemoryError>;
201
202    /// All pending tasks whose `due_at` is in the past.
203    async fn get_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError>;
204
205    /// Atomically claim every due task (status 'pending' -> 'claimed' in a
206    /// single statement) and return the claimed rows. When several pollers
207    /// share a store, each due task is handed to exactly one of them; a
208    /// claim abandoned by a dead claimer becomes reclaimable after a
209    /// timeout. The claim is released by `complete_task` (recurring tasks
210    /// return to 'pending' at the next due time) or `fail_task` (retries
211    /// return to 'pending').
212    async fn claim_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError>;
213
214    /// Record one execution of a scheduled task. `status` must be
215    /// `"completed"` or `"failed"`. Returns the new run id.
216    #[allow(clippy::too_many_arguments)]
217    async fn record_task_run(
218        &self,
219        task_id: &str,
220        started_at: &str,
221        status: &str,
222        result: Option<&str>,
223        error: Option<&str>,
224        tokens_used: Option<u64>,
225    ) -> Result<String, MemoryError>;
226
227    /// Recorded runs for tasks whose id starts with `task_id_prefix`,
228    /// newest first, capped at `limit`.
229    async fn list_task_runs(
230        &self,
231        task_id_prefix: &str,
232        limit: u32,
233    ) -> Result<Vec<TaskRunRecord>, MemoryError>;
234}
235
236#[async_trait]
237impl MemoryStore for Store {
238    async fn close_current_conversation(
239        &self,
240        channel: &str,
241        sender_id: &str,
242        project: &str,
243    ) -> Result<bool, MemoryError> {
244        Store::close_current_conversation(self, channel, sender_id, project).await
245    }
246
247    async fn get_memory_stats(&self, sender_id: &str) -> Result<(i64, i64, i64, i64), MemoryError> {
248        Store::get_memory_stats(self, sender_id).await
249    }
250
251    async fn db_size(&self) -> Result<u64, MemoryError> {
252        Store::db_size(self).await
253    }
254
255    async fn get_total_usage(&self) -> Result<UsageSummary, MemoryError> {
256        Store::get_total_usage(self).await
257    }
258
259    async fn get_history(
260        &self,
261        channel: &str,
262        sender_id: &str,
263        limit: i64,
264    ) -> Result<Vec<HistoryRow>, MemoryError> {
265        Store::get_history(self, channel, sender_id, limit).await
266    }
267
268    async fn search_messages(
269        &self,
270        query: &str,
271        exclude_conversation_id: &str,
272        sender_id: &str,
273        limit: i64,
274        since: Option<SystemTime>,
275    ) -> Result<Vec<MessageRow>, MemoryError> {
276        Store::search_messages(
277            self,
278            query,
279            exclude_conversation_id,
280            sender_id,
281            limit,
282            since,
283        )
284        .await
285    }
286
287    async fn get_message_by_id(&self, id: &str) -> Result<Option<MessageRow>, MemoryError> {
288        Store::get_message_by_id(self, id).await
289    }
290
291    async fn store_fact(&self, sender_id: &str, key: &str, value: &str) -> Result<(), MemoryError> {
292        Store::store_fact(self, sender_id, key, value).await
293    }
294
295    async fn get_fact(&self, sender_id: &str, key: &str) -> Result<Option<String>, MemoryError> {
296        Store::get_fact(self, sender_id, key).await
297    }
298
299    async fn get_facts(&self, sender_id: &str) -> Result<Vec<(String, String)>, MemoryError> {
300        Store::get_facts(self, sender_id).await
301    }
302
303    async fn soft_delete_fact(&self, sender_id: &str, key: &str) -> Result<bool, MemoryError> {
304        Store::soft_delete_fact(self, sender_id, key).await
305    }
306
307    async fn soft_delete_facts(
308        &self,
309        sender_id: &str,
310        key: Option<&str>,
311    ) -> Result<u64, MemoryError> {
312        Store::soft_delete_facts(self, sender_id, key).await
313    }
314
315    async fn list_soft_deleted_facts(
316        &self,
317        sender_id: &str,
318    ) -> Result<Vec<(String, String, String)>, MemoryError> {
319        Store::list_soft_deleted_facts(self, sender_id).await
320    }
321
322    async fn save_observation(&self, entry: SaveEntry) -> Result<Observation, MemoryError> {
323        Store::save_observation(self, entry).await
324    }
325
326    async fn get_observation_by_id(&self, id: &str) -> Result<Option<Observation>, MemoryError> {
327        Store::get_observation_by_id(self, id).await
328    }
329
330    async fn search_observations(
331        &self,
332        query: &str,
333        sender_id: &str,
334        limit: i64,
335        since: Option<SystemTime>,
336        kind: Option<ObservationType>,
337    ) -> Result<Vec<Observation>, MemoryError> {
338        Store::search_observations(self, query, sender_id, limit, since, kind).await
339    }
340
341    async fn soft_delete_observation(&self, id: &str) -> Result<bool, MemoryError> {
342        Store::soft_delete_observation(self, id).await
343    }
344
345    async fn list_soft_deleted_observations(
346        &self,
347        sender_id: &str,
348    ) -> Result<Vec<Observation>, MemoryError> {
349        Store::list_soft_deleted_observations(self, sender_id).await
350    }
351
352    async fn create_task(
353        &self,
354        channel: &str,
355        sender_id: &str,
356        reply_target: &str,
357        description: &str,
358        due_at: &str,
359        repeat: Option<&str>,
360        task_type: &str,
361        project: &str,
362    ) -> Result<String, MemoryError> {
363        Store::create_task(
364            self,
365            channel,
366            sender_id,
367            reply_target,
368            description,
369            due_at,
370            repeat,
371            task_type,
372            project,
373        )
374        .await
375    }
376
377    async fn get_tasks_for_sender(
378        &self,
379        sender_id: &str,
380    ) -> Result<Vec<(String, String, String, Option<String>, String, String)>, MemoryError> {
381        Store::get_tasks_for_sender(self, sender_id).await
382    }
383
384    async fn complete_task(&self, id: &str, repeat: Option<&str>) -> Result<(), MemoryError> {
385        Store::complete_task(self, id, repeat).await
386    }
387
388    async fn fail_task(
389        &self,
390        id: &str,
391        error: &str,
392        max_retries: u32,
393    ) -> Result<bool, MemoryError> {
394        Store::fail_task(self, id, error, max_retries).await
395    }
396
397    async fn cancel_task(&self, id_prefix: &str, sender_id: &str) -> Result<bool, MemoryError> {
398        Store::cancel_task(self, id_prefix, sender_id).await
399    }
400
401    async fn get_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError> {
402        Store::get_due_tasks(self).await
403    }
404
405    async fn claim_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError> {
406        Store::claim_due_tasks(self).await
407    }
408
409    async fn record_task_run(
410        &self,
411        task_id: &str,
412        started_at: &str,
413        status: &str,
414        result: Option<&str>,
415        error: Option<&str>,
416        tokens_used: Option<u64>,
417    ) -> Result<String, MemoryError> {
418        Store::record_task_run(
419            self,
420            task_id,
421            started_at,
422            status,
423            result,
424            error,
425            tokens_used,
426        )
427        .await
428    }
429
430    async fn list_task_runs(
431        &self,
432        task_id_prefix: &str,
433        limit: u32,
434    ) -> Result<Vec<TaskRunRecord>, MemoryError> {
435        Store::list_task_runs(self, task_id_prefix, limit).await
436    }
437}
438
439/// Expose a [`Store`] through the [`MemoryStore`] trait surface.
440///
441/// `Store` already implements `Clone` (its `SqlitePool` is internally
442/// reference-counted); cloning here shares the same connection pool.
443pub fn into_handle(store: Store) -> Arc<dyn MemoryStore> {
444    Arc::new(store)
445}