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