1use 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 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
531fn 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 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 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 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 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}