Skip to main content

harn_session_store/
sqlite.rs

1//! SQLite-backed [`SessionStore`].
2//!
3//! Single-file durable backend suitable for self-hosted deployments and
4//! the TUI's persistent session DB. Schema versioning is intentionally
5//! minimal — one `schema_version` table; future migrations bump the
6//! version and run guarded ALTERs. The Postgres backend (issue #2500)
7//! follows the same shape so consumers can swap by config.
8
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11use std::time::Duration;
12
13use async_trait::async_trait;
14use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
15use uuid::Uuid;
16
17use super::event::{
18    now_ms_and_rfc3339, AppendEvent, EventId, EventSignature, SessionEventKind, StoredEvent,
19};
20use super::redaction::{
21    prepare_append_event, prepare_stored_events_for_persistence, redact_stored_events,
22};
23use super::signing::{
24    chain_root_fold, chain_root_hash, chain_root_init, compute_record_hash, re_anchor_events,
25    verify_session_chain,
26};
27use super::store::{
28    CreateSession, EventPage, ForkResult, ImportResult, ImportSession, ListFilter, ReadRange,
29    SessionId, SessionImporter, SessionMeta, SessionStatus, SessionStore, Snapshot, SnapshotId,
30    StoreError, StoreHooks, StoreResult, TruncateResult, VerifyReport, MAX_READ_BATCH,
31};
32
33const SCHEMA_VERSION: i64 = 2;
34const DEFAULT_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
35
36#[derive(Clone)]
37pub struct SqliteSessionStore {
38    conn: Arc<Mutex<Connection>>,
39    hooks: Arc<StoreHooks>,
40    path: PathBuf,
41}
42
43impl SqliteSessionStore {
44    pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
45        Self::open_with_hooks(path, StoreHooks::default())
46    }
47
48    pub fn open_in_memory() -> StoreResult<Self> {
49        let conn =
50            Connection::open_in_memory().map_err(|error| StoreError::Backend(error.to_string()))?;
51        Self::initialize(conn, PathBuf::from(":memory:"), StoreHooks::default())
52    }
53
54    pub fn open_with_hooks(path: impl AsRef<Path>, hooks: StoreHooks) -> StoreResult<Self> {
55        let path = path.as_ref().to_path_buf();
56        if let Some(parent) = path.parent() {
57            if !parent.as_os_str().is_empty() {
58                std::fs::create_dir_all(parent)
59                    .map_err(|error| StoreError::Backend(error.to_string()))?;
60            }
61        }
62        let conn =
63            Connection::open(&path).map_err(|error| StoreError::Backend(error.to_string()))?;
64        Self::initialize(conn, path, hooks)
65    }
66
67    fn initialize(conn: Connection, path: PathBuf, hooks: StoreHooks) -> StoreResult<Self> {
68        conn.busy_timeout(DEFAULT_BUSY_TIMEOUT)
69            .map_err(|error| StoreError::Backend(error.to_string()))?;
70        conn.pragma_update(None, "foreign_keys", "ON")
71            .map_err(|error| StoreError::Backend(error.to_string()))?;
72        conn.pragma_update(None, "journal_mode", "WAL")
73            .map_err(|error| StoreError::Backend(error.to_string()))?;
74        conn.pragma_update(None, "synchronous", "NORMAL")
75            .map_err(|error| StoreError::Backend(error.to_string()))?;
76        conn.execute_batch(
77            "
78            CREATE TABLE IF NOT EXISTS schema_version (
79                version INTEGER NOT NULL PRIMARY KEY
80            );
81            CREATE TABLE IF NOT EXISTS sessions (
82                id                  TEXT PRIMARY KEY,
83                tenant_id           TEXT,
84                persona             TEXT,
85                parent_session_id   TEXT,
86                created_at_ms       INTEGER NOT NULL,
87                created_at          TEXT NOT NULL,
88                updated_at_ms       INTEGER NOT NULL,
89                updated_at          TEXT NOT NULL,
90                status              TEXT NOT NULL,
91                event_count         INTEGER NOT NULL DEFAULT 0,
92                last_event_id       INTEGER,
93                chain_root_hash     TEXT,
94                closed_at_ms        INTEGER,
95                closed_at           TEXT,
96                soft_deleted_at_ms  INTEGER,
97                ttl_seconds         INTEGER,
98                tags_json           TEXT NOT NULL DEFAULT '[]',
99                attributes_json     TEXT NOT NULL DEFAULT '{}',
100                next_event_id       INTEGER NOT NULL DEFAULT 1
101            );
102            CREATE INDEX IF NOT EXISTS sessions_tenant_created
103                ON sessions(tenant_id, created_at_ms);
104            CREATE INDEX IF NOT EXISTS sessions_status
105                ON sessions(status);
106            CREATE TABLE IF NOT EXISTS session_events (
107                session_id          TEXT NOT NULL,
108                event_id            INTEGER NOT NULL,
109                tenant_id           TEXT,
110                parent_event_id     INTEGER,
111                actor               TEXT,
112                kind                TEXT NOT NULL,
113                custom_kind         TEXT,
114                payload_json        TEXT NOT NULL,
115                tags_json           TEXT NOT NULL DEFAULT '[]',
116                headers_json        TEXT NOT NULL DEFAULT '{}',
117                ts_ms               INTEGER NOT NULL,
118                ts                  TEXT NOT NULL,
119                record_hash         TEXT NOT NULL,
120                prev_hash           TEXT,
121                signature_json      TEXT,
122                PRIMARY KEY (session_id, event_id),
123                FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
124            );
125            CREATE INDEX IF NOT EXISTS session_events_ts
126                ON session_events(session_id, ts_ms);
127            CREATE TABLE IF NOT EXISTS session_imports (
128                source_id       TEXT PRIMARY KEY,
129                source_digest   TEXT NOT NULL,
130                session_id      TEXT NOT NULL,
131                event_count     INTEGER NOT NULL
132            );
133            CREATE TABLE IF NOT EXISTS session_tags (
134                session_id  TEXT NOT NULL,
135                tag         TEXT NOT NULL,
136                PRIMARY KEY (session_id, tag),
137                FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
138            );
139            CREATE INDEX IF NOT EXISTS session_tags_by_tag
140                ON session_tags(tag, session_id);
141            CREATE TABLE IF NOT EXISTS session_snapshots (
142                id              TEXT PRIMARY KEY,
143                session_id      TEXT NOT NULL,
144                captured_at_ms  INTEGER NOT NULL,
145                captured_at     TEXT NOT NULL,
146                body_json       TEXT NOT NULL,
147                FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
148            );
149            ",
150        )
151        .map_err(|error| StoreError::Backend(error.to_string()))?;
152        let previous_schema_version = conn
153            .query_row("SELECT MAX(version) FROM schema_version", [], |row| {
154                row.get::<_, Option<i64>>(0)
155            })
156            .map_err(map_sql)?
157            .unwrap_or_default();
158        if previous_schema_version > SCHEMA_VERSION {
159            return Err(StoreError::Backend(format!(
160                "session store schema version {previous_schema_version} is newer than supported version {SCHEMA_VERSION}"
161            )));
162        }
163        if previous_schema_version < SCHEMA_VERSION {
164            // Foreign-key enforcement is connection-local and does not repair
165            // child rows orphaned by v1. This guarded cleanup runs once while
166            // upgrading to v2.
167            conn.execute_batch(
168                "DELETE FROM session_events
169                   WHERE NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.id = session_events.session_id);
170                 DELETE FROM session_tags
171                   WHERE NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.id = session_tags.session_id);
172                 DELETE FROM session_snapshots
173                   WHERE NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.id = session_snapshots.session_id);",
174            )
175            .map_err(|error| StoreError::Backend(error.to_string()))?;
176        }
177        conn.execute(
178            "INSERT OR IGNORE INTO schema_version(version) VALUES (?1)",
179            params![SCHEMA_VERSION],
180        )
181        .map_err(|error| StoreError::Backend(error.to_string()))?;
182        Ok(Self {
183            conn: Arc::new(Mutex::new(conn)),
184            hooks: Arc::new(hooks),
185            path,
186        })
187    }
188
189    pub fn path(&self) -> &Path {
190        &self.path
191    }
192
193    fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
194        self.conn.lock().unwrap_or_else(|e| e.into_inner())
195    }
196}
197
198fn map_sql(error: rusqlite::Error) -> StoreError {
199    StoreError::Backend(error.to_string())
200}
201
202fn map_create_sql(error: rusqlite::Error, session_id: &str) -> StoreError {
203    if matches!(
204        error,
205        rusqlite::Error::SqliteFailure(ref inner, _)
206            if inner.code == rusqlite::ErrorCode::ConstraintViolation
207    ) {
208        StoreError::AlreadyExists(session_id.to_string())
209    } else {
210        map_sql(error)
211    }
212}
213
214fn kind_to_sql(kind: &SessionEventKind) -> (String, Option<String>) {
215    match kind {
216        SessionEventKind::Custom { custom_type } => {
217            ("custom".to_string(), Some(custom_type.clone()))
218        }
219        other => (other.discriminator().to_string(), None),
220    }
221}
222
223fn kind_from_sql(kind: &str, custom_kind: Option<String>) -> StoreResult<SessionEventKind> {
224    Ok(match kind {
225        "message" => SessionEventKind::Message,
226        "tool_call" => SessionEventKind::ToolCall,
227        "tool_result" => SessionEventKind::ToolResult,
228        "plan" => SessionEventKind::Plan,
229        "compaction" => SessionEventKind::Compaction,
230        "system_reminder" => SessionEventKind::SystemReminder,
231        "hypothesis" => SessionEventKind::Hypothesis,
232        "receipt" => SessionEventKind::Receipt,
233        "reminder" => SessionEventKind::Reminder,
234        "permission_decision" => SessionEventKind::PermissionDecision,
235        "custom" => SessionEventKind::Custom {
236            custom_type: custom_kind.unwrap_or_default(),
237        },
238        other => {
239            return Err(StoreError::Backend(format!(
240                "unknown event kind '{other}' in storage"
241            )))
242        }
243    })
244}
245
246fn status_to_sql(status: SessionStatus) -> &'static str {
247    match status {
248        SessionStatus::Open => "open",
249        SessionStatus::Closed => "closed",
250        SessionStatus::SoftDeleted => "soft_deleted",
251        SessionStatus::HardDeleted => "hard_deleted",
252    }
253}
254
255fn status_from_sql(value: &str) -> StoreResult<SessionStatus> {
256    Ok(match value {
257        "open" => SessionStatus::Open,
258        "closed" => SessionStatus::Closed,
259        "soft_deleted" => SessionStatus::SoftDeleted,
260        "hard_deleted" => SessionStatus::HardDeleted,
261        other => {
262            return Err(StoreError::Backend(format!(
263                "unknown session status '{other}' in storage"
264            )))
265        }
266    })
267}
268
269fn insert_session_tags(conn: &Connection, session_id: &str, tags: &[String]) -> StoreResult<()> {
270    if tags.is_empty() {
271        return Ok(());
272    }
273    let mut stmt = conn
274        .prepare("INSERT OR IGNORE INTO session_tags (session_id, tag) VALUES (?1, ?2)")
275        .map_err(map_sql)?;
276    for tag in tags {
277        stmt.execute(params![session_id, tag]).map_err(map_sql)?;
278    }
279    Ok(())
280}
281
282fn insert_session(
283    conn: &Connection,
284    meta: &SessionMeta,
285    next_event_id: EventId,
286) -> StoreResult<()> {
287    let tags_json = serde_json::to_string(&meta.tags).unwrap_or_else(|_| "[]".into());
288    let attrs_json = serde_json::to_string(&meta.attributes).unwrap_or_else(|_| "{}".into());
289    conn.execute(
290        "INSERT INTO sessions (
291            id, tenant_id, persona, parent_session_id, created_at_ms, created_at,
292            updated_at_ms, updated_at, status, event_count, last_event_id,
293            chain_root_hash, closed_at_ms, closed_at, soft_deleted_at_ms,
294            ttl_seconds, tags_json, attributes_json, next_event_id
295         ) VALUES (
296            ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
297            ?15, ?16, ?17, ?18, ?19
298         )",
299        params![
300            meta.id,
301            meta.tenant_id,
302            meta.persona,
303            meta.parent_session_id,
304            meta.created_at_ms,
305            meta.created_at,
306            meta.updated_at_ms,
307            meta.updated_at,
308            status_to_sql(meta.status),
309            meta.event_count as i64,
310            meta.last_event_id.map(|value| value as i64),
311            meta.chain_root_hash,
312            meta.closed_at_ms,
313            meta.closed_at,
314            meta.soft_deleted_at_ms,
315            meta.ttl_seconds.map(|value| value as i64),
316            tags_json,
317            attrs_json,
318            next_event_id as i64,
319        ],
320    )
321    .map_err(|error| map_create_sql(error, &meta.id))?;
322    insert_session_tags(conn, &meta.id, &meta.tags)?;
323    Ok(())
324}
325
326fn read_session_meta(conn: &Connection, session_id: &str) -> StoreResult<(SessionMeta, EventId)> {
327    let row = conn
328        .query_row(
329            "SELECT tenant_id, persona, parent_session_id, created_at_ms, created_at,
330                    updated_at_ms, updated_at, status, event_count, last_event_id,
331                    chain_root_hash, closed_at_ms, closed_at, soft_deleted_at_ms,
332                    ttl_seconds, tags_json, attributes_json, next_event_id
333             FROM sessions WHERE id = ?1",
334            params![session_id],
335            |row| {
336                Ok((
337                    row.get::<_, Option<String>>(0)?,
338                    row.get::<_, Option<String>>(1)?,
339                    row.get::<_, Option<String>>(2)?,
340                    row.get::<_, i64>(3)?,
341                    row.get::<_, String>(4)?,
342                    row.get::<_, i64>(5)?,
343                    row.get::<_, String>(6)?,
344                    row.get::<_, String>(7)?,
345                    row.get::<_, i64>(8)?,
346                    row.get::<_, Option<i64>>(9)?,
347                    row.get::<_, Option<String>>(10)?,
348                    row.get::<_, Option<i64>>(11)?,
349                    row.get::<_, Option<String>>(12)?,
350                    row.get::<_, Option<i64>>(13)?,
351                    row.get::<_, Option<i64>>(14)?,
352                    row.get::<_, String>(15)?,
353                    row.get::<_, String>(16)?,
354                    row.get::<_, i64>(17)?,
355                ))
356            },
357        )
358        .optional()
359        .map_err(map_sql)?
360        .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
361    let (
362        tenant_id,
363        persona,
364        parent_session_id,
365        created_at_ms,
366        created_at,
367        updated_at_ms,
368        updated_at,
369        status,
370        event_count,
371        last_event_id,
372        chain_root_hash,
373        closed_at_ms,
374        closed_at,
375        soft_deleted_at_ms,
376        ttl_seconds,
377        tags_json,
378        attrs_json,
379        next_event_id,
380    ) = row;
381    let tags = serde_json::from_str(&tags_json).unwrap_or_default();
382    let attributes = serde_json::from_str(&attrs_json).unwrap_or_default();
383    let meta = SessionMeta {
384        id: session_id.to_string(),
385        tenant_id,
386        persona,
387        parent_session_id,
388        created_at_ms,
389        created_at,
390        updated_at_ms,
391        updated_at,
392        status: status_from_sql(&status)?,
393        event_count: event_count as usize,
394        last_event_id: last_event_id.map(|value| value as EventId),
395        chain_root_hash,
396        closed_at_ms,
397        closed_at,
398        soft_deleted_at_ms,
399        ttl_seconds: ttl_seconds.map(|value| value as u64),
400        tags,
401        attributes,
402    };
403    Ok((meta, next_event_id as EventId))
404}
405
406fn insert_event(conn: &Connection, event: &StoredEvent) -> StoreResult<()> {
407    let (kind, custom_kind) = kind_to_sql(&event.kind);
408    let payload_json = serde_json::to_string(&event.payload)
409        .map_err(|error| StoreError::Backend(error.to_string()))?;
410    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".into());
411    let headers_json = serde_json::to_string(&event.headers).unwrap_or_else(|_| "{}".into());
412    let signature_json = event
413        .signed_by
414        .as_ref()
415        .map(|sig| serde_json::to_string(sig).unwrap_or_else(|_| "null".into()));
416    conn.execute(
417        "INSERT INTO session_events (
418            session_id, event_id, tenant_id, parent_event_id, actor, kind, custom_kind,
419            payload_json, tags_json, headers_json, ts_ms, ts, record_hash, prev_hash,
420            signature_json
421        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
422        params![
423            event.session_id,
424            event.event_id as i64,
425            event.tenant_id,
426            event.parent_event_id.map(|value| value as i64),
427            event.actor,
428            kind,
429            custom_kind,
430            payload_json,
431            tags_json,
432            headers_json,
433            event.ts_ms,
434            event.ts,
435            event.record_hash,
436            event.prev_hash,
437            signature_json,
438        ],
439    )
440    .map_err(map_sql)?;
441    Ok(())
442}
443
444fn read_event(row: &rusqlite::Row) -> Result<StoredEvent, rusqlite::Error> {
445    let session_id: String = row.get(0)?;
446    let event_id: i64 = row.get(1)?;
447    let tenant_id: Option<String> = row.get(2)?;
448    let parent_event_id: Option<i64> = row.get(3)?;
449    let actor: Option<String> = row.get(4)?;
450    let kind: String = row.get(5)?;
451    let custom_kind: Option<String> = row.get(6)?;
452    let payload_json: String = row.get(7)?;
453    let tags_json: String = row.get(8)?;
454    let headers_json: String = row.get(9)?;
455    let ts_ms: i64 = row.get(10)?;
456    let ts: String = row.get(11)?;
457    let record_hash: String = row.get(12)?;
458    let prev_hash: Option<String> = row.get(13)?;
459    let signature_json: Option<String> = row.get(14)?;
460    let resolved_kind = kind_from_sql(&kind, custom_kind).map_err(|error| {
461        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, error.into())
462    })?;
463    let payload = serde_json::from_str(&payload_json).map_err(|error| {
464        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, error.into())
465    })?;
466    let tags = serde_json::from_str(&tags_json).unwrap_or_default();
467    let headers = serde_json::from_str(&headers_json).unwrap_or_default();
468    let signed_by = signature_json
469        .as_ref()
470        .map(|value| serde_json::from_str::<EventSignature>(value))
471        .transpose()
472        .map_err(|error| {
473            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, error.into())
474        })?;
475    Ok(StoredEvent {
476        event_id: event_id as EventId,
477        session_id,
478        tenant_id,
479        parent_event_id: parent_event_id.map(|value| value as EventId),
480        actor,
481        kind: resolved_kind,
482        payload,
483        tags,
484        headers,
485        ts_ms,
486        ts,
487        record_hash,
488        prev_hash,
489        signed_by,
490    })
491}
492
493fn load_all_events(conn: &Connection, session_id: &str) -> StoreResult<Vec<StoredEvent>> {
494    let mut stmt = conn
495        .prepare(
496            "SELECT session_id, event_id, tenant_id, parent_event_id, actor, kind,
497                    custom_kind, payload_json, tags_json, headers_json, ts_ms, ts,
498                    record_hash, prev_hash, signature_json
499             FROM session_events WHERE session_id = ?1 ORDER BY event_id ASC",
500        )
501        .map_err(map_sql)?;
502    let rows = stmt
503        .query_map(params![session_id], read_event)
504        .map_err(map_sql)?;
505    let mut out = Vec::new();
506    for row in rows {
507        out.push(row.map_err(map_sql)?);
508    }
509    Ok(out)
510}
511
512fn read_import(conn: &Connection, source_id: &str) -> StoreResult<Option<ImportResult>> {
513    conn.query_row(
514        "SELECT source_digest, session_id, event_count
515         FROM session_imports WHERE source_id = ?1",
516        params![source_id],
517        |row| {
518            Ok(ImportResult {
519                source_id: source_id.to_string(),
520                source_digest: row.get(0)?,
521                session_id: row.get(1)?,
522                event_count: row.get::<_, i64>(2)? as usize,
523                imported: false,
524            })
525        },
526    )
527    .optional()
528    .map_err(map_sql)
529}
530
531/// Core append logic, operating on a caller-owned connection (typically a
532/// transaction). Redacts, validates, links, signs (when an event signer
533/// is configured), inserts the event, and advances the session counters —
534/// but does **not** commit. `append` wraps this in its own transaction;
535/// `close` reuses it so the receipt insert, its signature, and the status
536/// flip all land in a single atomic transaction.
537fn append_in_tx(
538    conn: &Connection,
539    hooks: &StoreHooks,
540    session_id: &str,
541    mut event: AppendEvent,
542) -> StoreResult<StoredEvent> {
543    prepare_append_event(hooks, &mut event)?;
544    let (mut meta, next_event_id) = read_session_meta(conn, session_id)?;
545    super::memory_helpers::validate_open(&meta)?;
546    if let Some(parent_event_id) = event.parent_event_id {
547        let exists: bool = conn
548            .query_row(
549                "SELECT 1 FROM session_events WHERE session_id = ?1 AND event_id = ?2",
550                params![session_id, parent_event_id as i64],
551                |_| Ok(true),
552            )
553            .optional()
554            .map_err(map_sql)?
555            .unwrap_or(false);
556        if !exists {
557            return Err(StoreError::InvalidInput(format!(
558                "parent_event_id {parent_event_id} not present in session"
559            )));
560        }
561    }
562    let prev_hash: Option<String> = conn
563        .query_row(
564            "SELECT record_hash FROM session_events
565             WHERE session_id = ?1 ORDER BY event_id DESC LIMIT 1",
566            params![session_id],
567            |row| row.get(0),
568        )
569        .optional()
570        .map_err(map_sql)?;
571    let (ts_ms, ts) = now_ms_and_rfc3339();
572    let mut stored = StoredEvent {
573        event_id: next_event_id,
574        session_id: session_id.to_string(),
575        tenant_id: meta.tenant_id.clone(),
576        parent_event_id: event.parent_event_id,
577        actor: event.actor,
578        kind: event.kind,
579        payload: event.payload,
580        tags: event.tags,
581        headers: event.headers,
582        ts_ms,
583        ts: ts.clone(),
584        record_hash: String::new(),
585        prev_hash,
586        signed_by: None,
587    };
588    stored.record_hash = compute_record_hash(&stored);
589    if let Some(signer) = hooks.event_signer.as_ref() {
590        stored.signed_by = Some(signer.sign_event(&stored));
591    }
592    insert_event(conn, &stored)?;
593    let prev_root = meta.chain_root_hash.clone().unwrap_or_else(chain_root_init);
594    let chain_root = chain_root_fold(&prev_root, &stored.record_hash);
595    meta.event_count = meta.event_count.saturating_add(1);
596    meta.last_event_id = Some(next_event_id);
597    meta.chain_root_hash = Some(chain_root);
598    meta.updated_at_ms = ts_ms;
599    meta.updated_at = ts;
600    conn.execute(
601        "UPDATE sessions SET event_count = ?1, last_event_id = ?2,
602                              chain_root_hash = ?3, updated_at_ms = ?4,
603                              updated_at = ?5, next_event_id = ?6 WHERE id = ?7",
604        params![
605            meta.event_count as i64,
606            meta.last_event_id.map(|value| value as i64),
607            meta.chain_root_hash,
608            meta.updated_at_ms,
609            meta.updated_at,
610            (next_event_id + 1) as i64,
611            session_id,
612        ],
613    )
614    .map_err(map_sql)?;
615    Ok(stored)
616}
617
618#[async_trait]
619impl SessionImporter for SqliteSessionStore {
620    async fn import(&self, request: ImportSession) -> StoreResult<ImportResult> {
621        request.validate()?;
622        let mut conn = self.lock();
623        let tx = conn
624            .transaction_with_behavior(TransactionBehavior::Immediate)
625            .map_err(map_sql)?;
626        if let Some(existing) = read_import(&tx, &request.source_id)? {
627            if existing.source_digest != request.source_digest {
628                return Err(StoreError::Conflict(format!(
629                    "import source '{}' changed digest",
630                    request.source_id
631                )));
632            }
633            return Ok(existing);
634        }
635
636        let meta = super::memory_helpers::meta_for_create(request.session);
637        if tx
638            .query_row(
639                "SELECT 1 FROM sessions WHERE id = ?1",
640                params![meta.id],
641                |_| Ok(()),
642            )
643            .optional()
644            .map_err(map_sql)?
645            .is_some()
646        {
647            return Err(StoreError::AlreadyExists(meta.id));
648        }
649        insert_session(&tx, &meta, 1)?;
650        let event_count = request.events.len();
651        for event in request.events {
652            append_in_tx(&tx, &self.hooks, &meta.id, event)?;
653        }
654        tx.execute(
655            "INSERT INTO session_imports (source_id, source_digest, session_id, event_count)
656             VALUES (?1, ?2, ?3, ?4)",
657            params![
658                request.source_id,
659                request.source_digest,
660                meta.id,
661                event_count as i64
662            ],
663        )
664        .map_err(map_sql)?;
665        tx.commit().map_err(map_sql)?;
666        Ok(ImportResult {
667            source_id: request.source_id,
668            source_digest: request.source_digest,
669            session_id: meta.id,
670            event_count,
671            imported: true,
672        })
673    }
674}
675
676#[async_trait]
677impl SessionStore for SqliteSessionStore {
678    fn hooks(&self) -> &StoreHooks {
679        &self.hooks
680    }
681
682    async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta> {
683        let meta = super::memory_helpers::meta_for_create(request);
684        let conn = self.lock();
685        insert_session(&conn, &meta, 1)?;
686        Ok(meta)
687    }
688
689    async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta> {
690        let conn = self.lock();
691        let (meta, _) = read_session_meta(&conn, session_id)?;
692        Ok(meta)
693    }
694
695    async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>> {
696        let conn = self.lock();
697        let limit = filter.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
698        // Pull the cursor's anchor row up front so the SQL can do
699        // keyset pagination on `(created_at_ms, id)` instead of scanning
700        // every prior row into memory.
701        let cursor_anchor: Option<(i64, String)> = filter
702            .cursor
703            .as_ref()
704            .map(|id| {
705                conn.query_row(
706                    "SELECT created_at_ms, id FROM sessions WHERE id = ?1",
707                    params![id],
708                    |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
709                )
710                .optional()
711                .map_err(map_sql)
712            })
713            .transpose()?
714            .flatten();
715
716        let mut sql = String::from("SELECT s.id FROM sessions s");
717        if filter.tag.is_some() {
718            sql.push_str(" INNER JOIN session_tags t ON t.session_id = s.id AND t.tag = :tag");
719        }
720        sql.push_str(" WHERE 1=1");
721        let mut args: Vec<(&'static str, rusqlite::types::Value)> = Vec::new();
722        if let Some(tag) = filter.tag {
723            args.push((":tag", tag.into()));
724        }
725        if let Some(tenant) = filter.tenant_id {
726            sql.push_str(" AND s.tenant_id = :tenant");
727            args.push((":tenant", tenant.into()));
728        }
729        if let Some(persona) = filter.persona {
730            sql.push_str(" AND s.persona = :persona");
731            args.push((":persona", persona.into()));
732        }
733        if let Some(status) = filter.status {
734            sql.push_str(" AND s.status = :status");
735            args.push((":status", status_to_sql(status).to_string().into()));
736        }
737        if let Some(after) = filter.created_after_ms {
738            sql.push_str(" AND s.created_at_ms >= :after");
739            args.push((":after", after.into()));
740        }
741        if let Some(before) = filter.created_before_ms {
742            sql.push_str(" AND s.created_at_ms <= :before");
743            args.push((":before", before.into()));
744        }
745        if let Some((anchor_ms, anchor_id)) = cursor_anchor {
746            sql.push_str(
747                " AND (s.created_at_ms > :cursor_ms OR (s.created_at_ms = :cursor_ms AND s.id > :cursor_id))",
748            );
749            args.push((":cursor_ms", anchor_ms.into()));
750            args.push((":cursor_id", anchor_id.into()));
751        }
752        sql.push_str(" ORDER BY s.created_at_ms ASC, s.id ASC LIMIT :limit");
753        args.push((":limit", limit.into()));
754
755        let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
756            .iter()
757            .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
758            .collect();
759        let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
760        let ids: Vec<String> = stmt
761            .query_map(named_args.as_slice(), |row| row.get(0))
762            .map_err(map_sql)?
763            .collect::<Result<_, _>>()
764            .map_err(map_sql)?;
765        let mut metas = Vec::with_capacity(ids.len());
766        for id in ids {
767            let (meta, _) = read_session_meta(&conn, &id)?;
768            metas.push(meta);
769        }
770        Ok(metas)
771    }
772
773    async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent> {
774        let mut conn = self.lock();
775        let tx = conn.transaction().map_err(map_sql)?;
776        let stored = append_in_tx(&tx, &self.hooks, session_id, event)?;
777        tx.commit().map_err(map_sql)?;
778        Ok(stored)
779    }
780
781    async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage> {
782        let conn = self.lock();
783        let from = range.from_event_id.unwrap_or(1) as i64;
784        // SQLite stores event_id as INTEGER (signed i64); use i64::MAX as
785        // the unbounded upper sentinel rather than casting EventId::MAX,
786        // which silently wraps to -1.
787        let to = range
788            .to_event_id
789            .map(|value| value as i64)
790            .unwrap_or(i64::MAX);
791        let limit = range.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
792        let mut stmt = conn
793            .prepare(
794                "SELECT session_id, event_id, tenant_id, parent_event_id, actor, kind,
795                        custom_kind, payload_json, tags_json, headers_json, ts_ms, ts,
796                        record_hash, prev_hash, signature_json
797                 FROM session_events
798                 WHERE session_id = ?1 AND event_id >= ?2 AND event_id <= ?3
799                 ORDER BY event_id ASC LIMIT ?4",
800            )
801            .map_err(map_sql)?;
802        let rows = stmt
803            .query_map(params![session_id, from, to, limit], read_event)
804            .map_err(map_sql)?;
805        let mut events = Vec::new();
806        for row in rows {
807            events.push(row.map_err(map_sql)?);
808        }
809        redact_stored_events(&self.hooks, &mut events)?;
810        let next_cursor = if events.len() as i64 == limit {
811            events.last().map(|tail| tail.event_id + 1)
812        } else {
813            None
814        };
815        Ok(EventPage {
816            events,
817            next_cursor,
818        })
819    }
820
821    async fn fork(
822        &self,
823        session_id: &str,
824        at_event_id: EventId,
825        child_id: Option<SessionId>,
826    ) -> StoreResult<ForkResult> {
827        let mut conn = self.lock();
828        let tx = conn.transaction().map_err(map_sql)?;
829        let (parent_meta, _) = read_session_meta(&tx, session_id)?;
830        let parent_events = load_all_events(&tx, session_id)?;
831        if !parent_events
832            .iter()
833            .any(|event| event.event_id == at_event_id)
834        {
835            return Err(StoreError::InvalidInput(format!(
836                "event {at_event_id} not found in session '{session_id}'"
837            )));
838        }
839        let new_id = child_id.unwrap_or_else(|| Uuid::now_v7().to_string());
840        let exists: bool = tx
841            .query_row(
842                "SELECT 1 FROM sessions WHERE id = ?1",
843                params![new_id],
844                |_| Ok(true),
845            )
846            .optional()
847            .map_err(map_sql)?
848            .unwrap_or(false);
849        if exists {
850            return Err(StoreError::AlreadyExists(new_id));
851        }
852        let (ms, text) = now_ms_and_rfc3339();
853        let mut child_meta = parent_meta.clone();
854        child_meta.id = new_id.clone();
855        child_meta.parent_session_id = Some(parent_meta.id);
856        child_meta.created_at_ms = ms;
857        child_meta.created_at = text.clone();
858        child_meta.updated_at_ms = ms;
859        child_meta.updated_at = text;
860        child_meta.status = SessionStatus::Open;
861        child_meta.closed_at_ms = None;
862        child_meta.closed_at = None;
863        child_meta.soft_deleted_at_ms = None;
864        let mut inherited: Vec<StoredEvent> = parent_events
865            .into_iter()
866            .filter(|event| event.event_id <= at_event_id)
867            .collect();
868        prepare_stored_events_for_persistence(&self.hooks, &mut inherited)?;
869        let copied = re_anchor_events(&inherited, &new_id);
870        child_meta.event_count = copied.len();
871        child_meta.last_event_id = copied.last().map(|tail| tail.event_id);
872        child_meta.chain_root_hash = Some(chain_root_hash(&copied));
873        let next_event_id = copied.last().map(|tail| tail.event_id + 1).unwrap_or(1);
874        insert_session(&tx, &child_meta, next_event_id)?;
875        for event in &copied {
876            insert_event(&tx, event)?;
877        }
878        tx.commit().map_err(map_sql)?;
879        Ok(ForkResult {
880            child_session_id: new_id,
881            forked_from_event_id: at_event_id,
882            copied_event_count: copied.len(),
883        })
884    }
885
886    async fn truncate(
887        &self,
888        session_id: &str,
889        at_event_id: EventId,
890    ) -> StoreResult<TruncateResult> {
891        let mut conn = self.lock();
892        let tx = conn.transaction().map_err(map_sql)?;
893        let (mut meta, _) = read_session_meta(&tx, session_id)?;
894        let exists: bool = tx
895            .query_row(
896                "SELECT 1 FROM session_events WHERE session_id = ?1 AND event_id = ?2",
897                params![session_id, at_event_id as i64],
898                |_| Ok(true),
899            )
900            .optional()
901            .map_err(map_sql)?
902            .unwrap_or(false);
903        if !exists {
904            return Err(StoreError::InvalidInput(format!(
905                "event {at_event_id} not found in session '{session_id}'"
906            )));
907        }
908        let removed: i64 = tx
909            .query_row(
910                "SELECT COUNT(*) FROM session_events
911                 WHERE session_id = ?1 AND event_id > ?2",
912                params![session_id, at_event_id as i64],
913                |row| row.get(0),
914            )
915            .map_err(map_sql)?;
916        tx.execute(
917            "DELETE FROM session_events WHERE session_id = ?1 AND event_id > ?2",
918            params![session_id, at_event_id as i64],
919        )
920        .map_err(map_sql)?;
921        let remaining_hashes: Vec<String> = {
922            let mut stmt = tx
923                .prepare(
924                    "SELECT record_hash FROM session_events
925                     WHERE session_id = ?1 ORDER BY event_id ASC",
926                )
927                .map_err(map_sql)?;
928            let rows = stmt
929                .query_map(params![session_id], |row| row.get::<_, String>(0))
930                .map_err(map_sql)?;
931            let mut out = Vec::new();
932            for row in rows {
933                out.push(row.map_err(map_sql)?);
934            }
935            out
936        };
937        let new_root = remaining_hashes
938            .iter()
939            .fold(chain_root_init(), |root, hash| chain_root_fold(&root, hash));
940        let (ms, text) = now_ms_and_rfc3339();
941        meta.event_count = remaining_hashes.len();
942        meta.last_event_id = Some(at_event_id);
943        meta.chain_root_hash = Some(new_root);
944        meta.updated_at_ms = ms;
945        meta.updated_at = text;
946        tx.execute(
947            "UPDATE sessions SET event_count = ?1, last_event_id = ?2,
948                                  chain_root_hash = ?3, updated_at_ms = ?4,
949                                  updated_at = ?5, next_event_id = ?6 WHERE id = ?7",
950            params![
951                meta.event_count as i64,
952                meta.last_event_id.map(|value| value as i64),
953                meta.chain_root_hash,
954                meta.updated_at_ms,
955                meta.updated_at,
956                (at_event_id + 1) as i64,
957                session_id,
958            ],
959        )
960        .map_err(map_sql)?;
961        tx.commit().map_err(map_sql)?;
962        Ok(TruncateResult {
963            kept_event_count: meta.event_count,
964            removed_event_count: removed as usize,
965            new_tip_event_id: meta.last_event_id,
966        })
967    }
968
969    async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot> {
970        let conn = self.lock();
971        let (meta, _) = read_session_meta(&conn, session_id)?;
972        let mut events = load_all_events(&conn, session_id)?;
973        redact_stored_events(&self.hooks, &mut events)?;
974        let (ms, text) = now_ms_and_rfc3339();
975        let snapshot = Snapshot {
976            id: SnapshotId(format!("snap-{}", Uuid::now_v7())),
977            session: meta,
978            events,
979            captured_at_ms: ms,
980            captured_at: text,
981        };
982        let body = serde_json::to_string(&snapshot)
983            .map_err(|error| StoreError::Backend(error.to_string()))?;
984        conn.execute(
985            "INSERT INTO session_snapshots (id, session_id, captured_at_ms, captured_at, body_json)
986             VALUES (?1, ?2, ?3, ?4, ?5)",
987            params![
988                snapshot.id.0,
989                snapshot.session.id,
990                snapshot.captured_at_ms,
991                snapshot.captured_at,
992                body,
993            ],
994        )
995        .map_err(map_sql)?;
996        Ok(snapshot)
997    }
998
999    async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot> {
1000        let conn = self.lock();
1001        let body: Option<String> = conn
1002            .query_row(
1003                "SELECT body_json FROM session_snapshots WHERE id = ?1",
1004                params![snapshot_id.0],
1005                |row| row.get(0),
1006            )
1007            .optional()
1008            .map_err(map_sql)?;
1009        let body = body.ok_or_else(|| StoreError::NotFound(snapshot_id.0.clone()))?;
1010        let mut snapshot: Snapshot =
1011            serde_json::from_str(&body).map_err(|error| StoreError::Backend(error.to_string()))?;
1012        redact_stored_events(&self.hooks, &mut snapshot.events)?;
1013        Ok(snapshot)
1014    }
1015
1016    async fn close(&self, session_id: &str) -> StoreResult<StoredEvent> {
1017        let mut conn = self.lock();
1018        let tx = conn.transaction().map_err(map_sql)?;
1019        // Read the pre-receipt chain root inside the transaction so the
1020        // root we sign is exactly the chain the receipt finalises, with
1021        // no window for a concurrent append to move the tip.
1022        let (meta, _) = read_session_meta(&tx, session_id)?;
1023        super::memory_helpers::validate_open(&meta)?;
1024        let record_root = match meta.chain_root_hash.clone() {
1025            Some(root) => root,
1026            None => chain_root_hash(&load_all_events(&tx, session_id)?),
1027        };
1028        let last_event_id = meta.last_event_id.unwrap_or(0);
1029        let payload =
1030            super::signing::canonical_receipt_payload(session_id, last_event_id, &record_root);
1031        let mut append = AppendEvent::new(SessionEventKind::Receipt, payload);
1032        append.actor = Some("session_store".into());
1033        let mut stored = append_in_tx(&tx, &self.hooks, session_id, append)?;
1034        // Intentionally replace the receipt's append-time per-event
1035        // signature with a receipt-root signature. The receipt's purpose
1036        // is to attest the chain root, so `verify()` special-cases it via
1037        // `verify_receipt_root` against the pre-receipt root rather than
1038        // the receipt event's own canonical bytes.
1039        if let Some(signer) = self
1040            .hooks
1041            .receipt_signer
1042            .as_ref()
1043            .or(self.hooks.event_signer.as_ref())
1044        {
1045            let signature = signer.sign_receipt(&record_root);
1046            let signature_json =
1047                serde_json::to_string(&signature).unwrap_or_else(|_| "null".into());
1048            tx.execute(
1049                "UPDATE session_events SET signature_json = ?1
1050                 WHERE session_id = ?2 AND event_id = ?3",
1051                params![signature_json, session_id, stored.event_id as i64],
1052            )
1053            .map_err(map_sql)?;
1054            stored.signed_by = Some(signature);
1055        }
1056        let (ms, text) = now_ms_and_rfc3339();
1057        tx.execute(
1058            "UPDATE sessions SET status = ?1, closed_at_ms = ?2, closed_at = ?3,
1059                                  updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
1060            params![status_to_sql(SessionStatus::Closed), ms, text, session_id,],
1061        )
1062        .map_err(map_sql)?;
1063        tx.commit().map_err(map_sql)?;
1064        Ok(stored)
1065    }
1066
1067    async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta> {
1068        let conn = self.lock();
1069        let (mut meta, _) = read_session_meta(&conn, session_id)?;
1070        match meta.status {
1071            SessionStatus::HardDeleted => return Err(StoreError::NotFound(session_id.to_string())),
1072            SessionStatus::SoftDeleted => return Ok(meta),
1073            _ => {}
1074        }
1075        let (ms, text) = now_ms_and_rfc3339();
1076        conn.execute(
1077            "UPDATE sessions SET status = ?1, soft_deleted_at_ms = ?2,
1078                                  updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
1079            params![
1080                status_to_sql(SessionStatus::SoftDeleted),
1081                ms,
1082                text,
1083                session_id,
1084            ],
1085        )
1086        .map_err(map_sql)?;
1087        meta.status = SessionStatus::SoftDeleted;
1088        meta.soft_deleted_at_ms = Some(ms);
1089        meta.updated_at_ms = ms;
1090        meta.updated_at = text;
1091        Ok(meta)
1092    }
1093
1094    async fn hard_delete(&self, session_id: &str) -> StoreResult<()> {
1095        let conn = self.lock();
1096        let removed = conn
1097            .execute("DELETE FROM sessions WHERE id = ?1", params![session_id])
1098            .map_err(map_sql)?;
1099        if removed == 0 {
1100            return Err(StoreError::NotFound(session_id.to_string()));
1101        }
1102        Ok(())
1103    }
1104
1105    async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport> {
1106        let conn = self.lock();
1107        let (meta, _) = read_session_meta(&conn, session_id)?;
1108        let events = load_all_events(&conn, session_id)?;
1109        let event_verifier = self
1110            .hooks
1111            .event_signer
1112            .as_ref()
1113            .map(|signer| signer.verifying_key());
1114        let receipt_verifier = self
1115            .hooks
1116            .receipt_signer
1117            .as_ref()
1118            .or(self.hooks.event_signer.as_ref())
1119            .map(|signer| signer.verifying_key());
1120        Ok(verify_session_chain(
1121            &meta,
1122            &events,
1123            event_verifier.as_ref(),
1124            receipt_verifier.as_ref(),
1125        ))
1126    }
1127}