1use 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 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 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
583fn 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 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 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 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 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;