feat(kebab-core + kebab-store-sqlite): p9-fb-17 chat session storage (V005)
도그푸딩 item 13/14 (multi-turn 영속화) — TUI Ask 의 "이전 대화
이어가기" + 향후 CLI `--session foo` (p9-fb-18) backing store. session
header + per-turn 두 테이블, ON DELETE CASCADE 로 reset --data-only 가
한꺼번에 wipe.
## 핵심 변경
- **SQLite V005 migration** `chat_sessions` (session_id PK + created_at
+ updated_at + title + config_snapshot_json) + `chat_turns` (turn_id
PK + session_id FK ON DELETE CASCADE + turn_index + question +
answer + citations_json + created_at + UNIQUE(session_id, turn_index))
+ `idx_chat_turns_session(session_id, turn_index)`. 모두 `STRICT`.
- **`kebab_core::ChatSessionRepo`** trait (6 method): create_session /
get_session / list_sessions(limit, ORDER BY updated_at DESC) /
delete_session / append_turn / list_turns(ORDER BY turn_index ASC)
- **`kebab_core::{ChatSessionRow, ChatTurnRow}`** structs — Serialize
+ Deserialize 둘 다 (CLI / wire 출력 호환)
- **`kebab-store-sqlite::SqliteStore`** impl 신규 모듈 `chat_sessions.rs`.
`append_turn` 이 insert + parent updated_at bump 같은 connection
에서 처리.
- **frozen design §5** 에 §5.7a chat_sessions / chat_turns 절 신설
(full schema + trait 메서드 6 개 명시).
## HOTFIXES (V004 → V005)
spec p9-fb-17 의 `V004__chat_sessions.sql` 가 p9-fb-19 의
`V004__kv.sql` (이미 머지) 와 refinery migration number 충돌. 무중단
정정: `V005__chat_sessions.sql` 로 시프트. schema / 동작 동일, 파일명
만 이동. HOTFIXES entry 추가.
## 테스트
- 9 신규 integration unit (create/get roundtrip, missing→None, PK
collision error, append+list ordered, dup turn_index error,
append bumps updated_at, delete CASCADE turns, list_sessions
ORDER BY updated_at DESC, list_sessions LIMIT)
- workspace 전체 `cargo test --workspace --no-fail-fast -j 1` exit 0
- `cargo clippy --workspace --all-targets -- -D warnings` clean
## 문서
- frozen design §5.7a 신설
- HANDOFF: 2026-05-03 entry
- HOTFIXES: V004 → V005 rename rationale
- spec status planned → in_progress
## Out of scope
- session 검색 / 필터 UI (p9-fb-18 의 `kebab ask --session list`
같은 admin command 가 후속)
- 다른 store backend (postgres 등) — trait 만 정의, impl 은 SQLite
unblocks p9-fb-18 (CLI session/repl).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
168
crates/kebab-store-sqlite/src/chat_sessions.rs
Normal file
168
crates/kebab-store-sqlite/src/chat_sessions.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! p9-fb-17: `ChatSessionRepo` impl for `SqliteStore`.
|
||||
//!
|
||||
//! `chat_sessions` + `chat_turns` tables (V005 migration) back the
|
||||
//! multi-turn conversation primitive (p9-fb-15 facade, p9-fb-16 TUI,
|
||||
//! p9-fb-18 CLI `--session`). The trait + row types live in
|
||||
//! `kebab-core::traits` so other store backends (postgres, …) can
|
||||
//! plug in without depending on this crate.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kebab_core::traits::{ChatSessionRepo, ChatSessionRow, ChatTurnRow};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::store::SqliteStore;
|
||||
|
||||
impl ChatSessionRepo for SqliteStore {
|
||||
fn create_session(&self, row: &ChatSessionRow) -> Result<()> {
|
||||
let conn = self.lock_conn();
|
||||
conn.execute(
|
||||
"INSERT INTO chat_sessions
|
||||
(session_id, created_at, updated_at, title, config_snapshot_json)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
params![
|
||||
row.session_id,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.title,
|
||||
row.config_snapshot_json,
|
||||
],
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("create_session")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_session(&self, session_id: &str) -> Result<Option<ChatSessionRow>> {
|
||||
let conn = self.read_conn();
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT session_id, created_at, updated_at, title, config_snapshot_json
|
||||
FROM chat_sessions WHERE session_id = ?",
|
||||
params![session_id],
|
||||
|r| {
|
||||
Ok(ChatSessionRow {
|
||||
session_id: r.get(0)?,
|
||||
created_at: r.get(1)?,
|
||||
updated_at: r.get(2)?,
|
||||
title: r.get(3)?,
|
||||
config_snapshot_json: r.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(StoreError::from)
|
||||
.context("get_session")?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn list_sessions(&self, limit: usize) -> Result<Vec<ChatSessionRow>> {
|
||||
let conn = self.read_conn();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT session_id, created_at, updated_at, title, config_snapshot_json
|
||||
FROM chat_sessions
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("list_sessions: prepare")?;
|
||||
let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||
let rows = stmt
|
||||
.query_map(params![limit_i64], |r| {
|
||||
Ok(ChatSessionRow {
|
||||
session_id: r.get(0)?,
|
||||
created_at: r.get(1)?,
|
||||
updated_at: r.get(2)?,
|
||||
title: r.get(3)?,
|
||||
config_snapshot_json: r.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(StoreError::from)
|
||||
.context("list_sessions: query")?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r.map_err(StoreError::from).context("list_sessions: row")?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete_session(&self, session_id: &str) -> Result<()> {
|
||||
let conn = self.lock_conn();
|
||||
// ON DELETE CASCADE in V005 migration sweeps `chat_turns`.
|
||||
conn.execute(
|
||||
"DELETE FROM chat_sessions WHERE session_id = ?",
|
||||
params![session_id],
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("delete_session")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_turn(&self, turn: &ChatTurnRow) -> Result<()> {
|
||||
let conn = self.lock_conn();
|
||||
// Wrap insert + parent updated_at in one transaction so a
|
||||
// crash between the two never leaves a turn under a stale
|
||||
// `updated_at`.
|
||||
let tx_result: Result<()> = (|| {
|
||||
conn.execute(
|
||||
"INSERT INTO chat_turns
|
||||
(turn_id, session_id, turn_index, question, answer,
|
||||
citations_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
turn.turn_id,
|
||||
turn.session_id,
|
||||
turn.turn_index,
|
||||
turn.question,
|
||||
turn.answer,
|
||||
turn.citations_json,
|
||||
turn.created_at,
|
||||
],
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("append_turn: insert")?;
|
||||
conn.execute(
|
||||
"UPDATE chat_sessions SET updated_at = ? WHERE session_id = ?",
|
||||
params![turn.created_at, turn.session_id],
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("append_turn: bump updated_at")?;
|
||||
Ok(())
|
||||
})();
|
||||
tx_result
|
||||
}
|
||||
|
||||
fn list_turns(&self, session_id: &str) -> Result<Vec<ChatTurnRow>> {
|
||||
let conn = self.read_conn();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT turn_id, session_id, turn_index, question, answer,
|
||||
citations_json, created_at
|
||||
FROM chat_turns
|
||||
WHERE session_id = ?
|
||||
ORDER BY turn_index ASC",
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("list_turns: prepare")?;
|
||||
let rows = stmt
|
||||
.query_map(params![session_id], |r| {
|
||||
Ok(ChatTurnRow {
|
||||
turn_id: r.get(0)?,
|
||||
session_id: r.get(1)?,
|
||||
turn_index: r.get(2)?,
|
||||
question: r.get(3)?,
|
||||
answer: r.get(4)?,
|
||||
citations_json: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(StoreError::from)
|
||||
.context("list_turns: query")?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r.map_err(StoreError::from).context("list_turns: row")?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
//! round-trip test off a real Markdown fixture.)
|
||||
|
||||
mod answers;
|
||||
mod chat_sessions;
|
||||
mod documents;
|
||||
mod embeddings;
|
||||
mod error;
|
||||
|
||||
Reference in New Issue
Block a user