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::store::{DueTask, Store, UsageSummary};
24use crate::types::{HistoryRow, MessageRow};
25
26/// Public trait surface over [`Store`].
27///
28/// Returned from `kernex-runtime::Runtime::store_handle()` as
29/// `Arc<dyn MemoryStore>`. Consumers should prefer this trait over the
30/// concrete `Store` type so future schema changes do not ripple into call
31/// sites.
32#[async_trait]
33pub trait MemoryStore: Send + Sync {
34    // --- conversations / messages ---
35
36    /// Mark the active conversation for `(channel, sender_id, project)` as
37    /// closed. Returns `true` if a row transitioned from active to closed.
38    async fn close_current_conversation(
39        &self,
40        channel: &str,
41        sender_id: &str,
42        project: &str,
43    ) -> Result<bool, MemoryError>;
44
45    /// Aggregate counters: `(conversation_count, message_count, fact_count)`.
46    async fn get_memory_stats(&self, sender_id: &str) -> Result<(i64, i64, i64), MemoryError>;
47
48    /// On-disk byte size of the SQLite database file.
49    async fn db_size(&self) -> Result<u64, MemoryError>;
50
51    /// Aggregate token usage across all sessions.
52    async fn get_total_usage(&self) -> Result<UsageSummary, MemoryError>;
53
54    /// Recent closed-conversation summaries for a given channel + sender,
55    /// newest first, capped at `limit`.
56    async fn get_history(
57        &self,
58        channel: &str,
59        sender_id: &str,
60        limit: i64,
61    ) -> Result<Vec<HistoryRow>, MemoryError>;
62
63    /// FTS5 full-text search over user messages, excluding the live
64    /// conversation. When `since` is `Some`, only rows with
65    /// `timestamp >= since` are returned and `limit` applies after the
66    /// recency filter.
67    async fn search_messages(
68        &self,
69        query: &str,
70        exclude_conversation_id: &str,
71        sender_id: &str,
72        limit: i64,
73        since: Option<SystemTime>,
74    ) -> Result<Vec<MessageRow>, MemoryError>;
75
76    /// Fetch a single message row by its UUID. Returns `None` when the
77    /// id is missing.
78    async fn get_message_by_id(&self, id: &str) -> Result<Option<MessageRow>, MemoryError>;
79
80    // --- facts (write paths plus soft-only delete on the trait) ---
81
82    /// Upsert a fact for `(sender_id, key)`. If the row was previously
83    /// soft-deleted, this clears `deleted_at` so the value is visible
84    /// again to default-filtered reads.
85    async fn store_fact(&self, sender_id: &str, key: &str, value: &str) -> Result<(), MemoryError>;
86
87    /// Read a single active fact by `(sender_id, key)`. Returns `None` if
88    /// the row is soft-deleted, missing, or never existed.
89    async fn get_fact(&self, sender_id: &str, key: &str) -> Result<Option<String>, MemoryError>;
90
91    /// Active (not soft-deleted) facts for `sender_id`.
92    async fn get_facts(&self, sender_id: &str) -> Result<Vec<(String, String)>, MemoryError>;
93
94    /// Soft-delete a single fact by setting its `deleted_at` timestamp.
95    /// Returns `true` if a row transitioned from active to deleted; `false`
96    /// if the row was already deleted, missing, or never existed.
97    async fn soft_delete_fact(&self, sender_id: &str, key: &str) -> Result<bool, MemoryError>;
98
99    /// Soft-delete multiple facts. With `Some(key)`, soft-deletes that
100    /// specific key. With `None`, soft-deletes every active fact for the
101    /// sender. Returns the count of rows that transitioned from active to
102    /// deleted.
103    async fn soft_delete_facts(
104        &self,
105        sender_id: &str,
106        key: Option<&str>,
107    ) -> Result<u64, MemoryError>;
108
109    /// Read soft-deleted facts (debug / recovery helper). Returns
110    /// `(key, value, deleted_at)` rows for `sender_id`.
111    async fn list_soft_deleted_facts(
112        &self,
113        sender_id: &str,
114    ) -> Result<Vec<(String, String, String)>, MemoryError>;
115
116    // --- scheduled tasks ---
117
118    /// Insert a new scheduled task. Returns the new task id.
119    #[allow(clippy::too_many_arguments)]
120    async fn create_task(
121        &self,
122        channel: &str,
123        sender_id: &str,
124        reply_target: &str,
125        description: &str,
126        due_at: &str,
127        repeat: Option<&str>,
128        task_type: &str,
129        project: &str,
130    ) -> Result<String, MemoryError>;
131
132    /// Pending tasks for `sender_id` as raw `(id, description, due_at,
133    /// repeat, task_type, project)` rows, ordered by `due_at` ascending.
134    async fn get_tasks_for_sender(
135        &self,
136        sender_id: &str,
137    ) -> Result<Vec<(String, String, String, Option<String>, String, String)>, MemoryError>;
138
139    /// Mark a task as completed. With `Some("daily")` / `Some("weekly")` /
140    /// etc., reschedules the next occurrence; with `None` or `Some("once")`,
141    /// the task transitions to a terminal status.
142    async fn complete_task(&self, id: &str, repeat: Option<&str>) -> Result<(), MemoryError>;
143
144    /// Record a task failure. Increments retry counter; transitions to a
145    /// terminal failed status when retries exhaust. Returns `true` if the
146    /// task transitioned to a terminal state.
147    async fn fail_task(&self, id: &str, error: &str, max_retries: u32)
148        -> Result<bool, MemoryError>;
149
150    /// Cancel a pending task whose id starts with `id_prefix`, scoped to
151    /// `sender_id`. Returns `true` if a row was cancelled.
152    async fn cancel_task(&self, id_prefix: &str, sender_id: &str) -> Result<bool, MemoryError>;
153
154    /// All pending tasks whose `due_at` is in the past.
155    async fn get_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError>;
156}
157
158#[async_trait]
159impl MemoryStore for Store {
160    async fn close_current_conversation(
161        &self,
162        channel: &str,
163        sender_id: &str,
164        project: &str,
165    ) -> Result<bool, MemoryError> {
166        Store::close_current_conversation(self, channel, sender_id, project).await
167    }
168
169    async fn get_memory_stats(&self, sender_id: &str) -> Result<(i64, i64, i64), MemoryError> {
170        Store::get_memory_stats(self, sender_id).await
171    }
172
173    async fn db_size(&self) -> Result<u64, MemoryError> {
174        Store::db_size(self).await
175    }
176
177    async fn get_total_usage(&self) -> Result<UsageSummary, MemoryError> {
178        Store::get_total_usage(self).await
179    }
180
181    async fn get_history(
182        &self,
183        channel: &str,
184        sender_id: &str,
185        limit: i64,
186    ) -> Result<Vec<HistoryRow>, MemoryError> {
187        Store::get_history(self, channel, sender_id, limit).await
188    }
189
190    async fn search_messages(
191        &self,
192        query: &str,
193        exclude_conversation_id: &str,
194        sender_id: &str,
195        limit: i64,
196        since: Option<SystemTime>,
197    ) -> Result<Vec<MessageRow>, MemoryError> {
198        Store::search_messages(
199            self,
200            query,
201            exclude_conversation_id,
202            sender_id,
203            limit,
204            since,
205        )
206        .await
207    }
208
209    async fn get_message_by_id(&self, id: &str) -> Result<Option<MessageRow>, MemoryError> {
210        Store::get_message_by_id(self, id).await
211    }
212
213    async fn store_fact(&self, sender_id: &str, key: &str, value: &str) -> Result<(), MemoryError> {
214        Store::store_fact(self, sender_id, key, value).await
215    }
216
217    async fn get_fact(&self, sender_id: &str, key: &str) -> Result<Option<String>, MemoryError> {
218        Store::get_fact(self, sender_id, key).await
219    }
220
221    async fn get_facts(&self, sender_id: &str) -> Result<Vec<(String, String)>, MemoryError> {
222        Store::get_facts(self, sender_id).await
223    }
224
225    async fn soft_delete_fact(&self, sender_id: &str, key: &str) -> Result<bool, MemoryError> {
226        Store::soft_delete_fact(self, sender_id, key).await
227    }
228
229    async fn soft_delete_facts(
230        &self,
231        sender_id: &str,
232        key: Option<&str>,
233    ) -> Result<u64, MemoryError> {
234        Store::soft_delete_facts(self, sender_id, key).await
235    }
236
237    async fn list_soft_deleted_facts(
238        &self,
239        sender_id: &str,
240    ) -> Result<Vec<(String, String, String)>, MemoryError> {
241        Store::list_soft_deleted_facts(self, sender_id).await
242    }
243
244    async fn create_task(
245        &self,
246        channel: &str,
247        sender_id: &str,
248        reply_target: &str,
249        description: &str,
250        due_at: &str,
251        repeat: Option<&str>,
252        task_type: &str,
253        project: &str,
254    ) -> Result<String, MemoryError> {
255        Store::create_task(
256            self,
257            channel,
258            sender_id,
259            reply_target,
260            description,
261            due_at,
262            repeat,
263            task_type,
264            project,
265        )
266        .await
267    }
268
269    async fn get_tasks_for_sender(
270        &self,
271        sender_id: &str,
272    ) -> Result<Vec<(String, String, String, Option<String>, String, String)>, MemoryError> {
273        Store::get_tasks_for_sender(self, sender_id).await
274    }
275
276    async fn complete_task(&self, id: &str, repeat: Option<&str>) -> Result<(), MemoryError> {
277        Store::complete_task(self, id, repeat).await
278    }
279
280    async fn fail_task(
281        &self,
282        id: &str,
283        error: &str,
284        max_retries: u32,
285    ) -> Result<bool, MemoryError> {
286        Store::fail_task(self, id, error, max_retries).await
287    }
288
289    async fn cancel_task(&self, id_prefix: &str, sender_id: &str) -> Result<bool, MemoryError> {
290        Store::cancel_task(self, id_prefix, sender_id).await
291    }
292
293    async fn get_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError> {
294        Store::get_due_tasks(self).await
295    }
296}
297
298/// Expose a [`Store`] through the [`MemoryStore`] trait surface.
299///
300/// `Store` already implements `Clone` (its `SqlitePool` is internally
301/// reference-counted); cloning here shares the same connection pool.
302pub fn into_handle(store: Store) -> Arc<dyn MemoryStore> {
303    Arc::new(store)
304}