1"""SQL-backed session store via the DatabaseProviderProtocol."""
2
3from __future__ import annotations
4
5from datetime import datetime
6from typing import Any
7
8from lexigram.contracts.ai.session import (
9 SessionCheckpoint,
10 SessionState,
11 SessionStatus,
12 SessionTurn,
13)
14from lexigram.contracts.data.sql.database import DatabaseProviderProtocol
15from lexigram.logging import (
16 get_logger,
17)
18from lexigram.serialization.backends.json import dumps_str, loads
19
20logger = get_logger(__name__)
21
22# SQL DDL
23_CREATE_SESSIONS_TABLE = """
24CREATE TABLE IF NOT EXISTS ai_sessions (
25 session_id TEXT PRIMARY KEY,
26 user_id TEXT NOT NULL,
27 status TEXT NOT NULL,
28 turns TEXT NOT NULL DEFAULT '[]',
29 metadata TEXT NOT NULL DEFAULT '{}',
30 active_tools TEXT NOT NULL DEFAULT '[]',
31 active_skills TEXT NOT NULL DEFAULT '[]',
32 system_prompt TEXT,
33 variables TEXT NOT NULL DEFAULT '{}',
34 created_at TEXT NOT NULL,
35 updated_at TEXT NOT NULL,
36 checkpoint_id TEXT,
37 total_tokens INTEGER NOT NULL DEFAULT 0,
38 total_cost REAL NOT NULL DEFAULT 0.0,
39 turn_count INTEGER NOT NULL DEFAULT 0,
40 parent_session_id TEXT,
41 branch_name TEXT
42);
43"""
44
45_CREATE_CHECKPOINTS_TABLE = """
46CREATE TABLE IF NOT EXISTS ai_checkpoints (
47 checkpoint_id TEXT PRIMARY KEY,
48 session_id TEXT NOT NULL,
49 state TEXT NOT NULL,
50 created_at TEXT NOT NULL,
51 parent_checkpoint_id TEXT,
52 metadata TEXT NOT NULL DEFAULT '{}'
53);
54"""
55
56
57def _row_to_state(row: Any) -> SessionState:
58 """Convert a DB row (sequence or mapping) to a ``SessionState``."""
59 turns_raw = loads(row[3] if isinstance(row, (list, tuple)) else row["turns"])
60 turns = [
61 SessionTurn(
62 turn_id=t["turn_id"],
63 role=t["role"],
64 content=t["content"],
65 timestamp=datetime.fromisoformat(t["timestamp"]),
66 tool_calls=t.get("tool_calls", []),
67 skill_results=t.get("skill_results", []),
68 metadata=t.get("metadata", {}),
69 tokens_used=t.get("tokens_used", 0),
70 cost=t.get("cost", 0.0),
71 model=t.get("model"),
72 provider=t.get("provider"),
73 )
74 for t in turns_raw
75 ]
76
77 def _col(row: Any, idx: int, name: str) -> Any:
78 if isinstance(row, (list, tuple)):
79 return row[idx]
80 return row[name]
81
82 return SessionState(
83 session_id=_col(row, 0, "session_id"),
84 user_id=_col(row, 1, "user_id"),
85 status=SessionStatus(_col(row, 2, "status")),
86 turns=turns,
87 metadata=loads(_col(row, 4, "metadata")),
88 active_tools=loads(_col(row, 5, "active_tools")),
89 active_skills=loads(_col(row, 6, "active_skills")),
90 system_prompt=_col(row, 7, "system_prompt"),
91 variables=loads(_col(row, 8, "variables")),
92 created_at=datetime.fromisoformat(_col(row, 9, "created_at")),
93 updated_at=datetime.fromisoformat(_col(row, 10, "updated_at")),
94 checkpoint_id=_col(row, 11, "checkpoint_id"),
95 total_tokens=_col(row, 12, "total_tokens"),
96 total_cost=_col(row, 13, "total_cost"),
97 turn_count=_col(row, 14, "turn_count"),
98 parent_session_id=_col(row, 15, "parent_session_id"),
99 branch_name=_col(row, 16, "branch_name"),
100 )
101
102
103class DatabaseSessionStore:
104 """SQL-backed session store for durable production storage.
105
106 Uses ``DatabaseProviderProtocol`` from ``lexigram-contracts`` so the
107 concrete driver (asyncpg, aiosqlite, …) is never imported directly.
108 All state is JSON-serialised for the SQL TEXT columns.
109
110 Tables used:
111 - ``ai_sessions`` — one row per session
112 - ``ai_checkpoints`` — one row per checkpoint
113
114 Args:
115 db: Any object implementing ``DatabaseProviderProtocol``
116 (provides ``scoped_context()`` and ``get_scoped_connection()``).
117 """
118
119 def __init__(self, db: DatabaseProviderProtocol) -> None:
120 self._db = db
121
122 async def _ensure_tables(self) -> None:
123 """Create the ai_sessions and ai_checkpoints tables if they do not exist."""
124 await self._db.execute(_CREATE_SESSIONS_TABLE)
125 await self._db.execute(_CREATE_CHECKPOINTS_TABLE)
126
127 # ------------------------------------------------------------------
128 # Session CRUD
129 # ------------------------------------------------------------------
130
131 async def save(self, state: SessionState) -> None:
132 """Upsert *state* in the database.
133
134 Args:
135 state: The session state to persist.
136 """
137 turns_json = dumps_str(
138 [
139 {
140 "turn_id": t.turn_id,
141 "role": t.role,
142 "content": t.content,
143 "timestamp": t.timestamp.isoformat(),
144 "tool_calls": t.tool_calls,
145 "skill_results": t.skill_results,
146 "metadata": t.metadata,
147 "tokens_used": t.tokens_used,
148 "cost": t.cost,
149 "model": t.model,
150 "provider": t.provider,
151 }
152 for t in state.turns
153 ]
154 )
155 sql = """
156 INSERT INTO ai_sessions
157 (session_id, user_id, status, turns, metadata, active_tools,
158 active_skills, system_prompt, variables, created_at, updated_at,
159 checkpoint_id, total_tokens, total_cost, turn_count,
160 parent_session_id, branch_name)
161 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
162 ON CONFLICT (session_id) DO UPDATE SET
163 status=EXCLUDED.status, turns=EXCLUDED.turns,
164 metadata=EXCLUDED.metadata, active_tools=EXCLUDED.active_tools,
165 active_skills=EXCLUDED.active_skills,
166 system_prompt=EXCLUDED.system_prompt,
167 variables=EXCLUDED.variables,
168 updated_at=EXCLUDED.updated_at,
169 checkpoint_id=EXCLUDED.checkpoint_id,
170 total_tokens=EXCLUDED.total_tokens,
171 total_cost=EXCLUDED.total_cost,
172 turn_count=EXCLUDED.turn_count
173 """
174 async with self._db.scoped_context():
175 conn = await self._db.get_scoped_connection()
176 await conn.execute(
177 sql,
178 state.session_id,
179 state.user_id,
180 state.status.value,
181 turns_json,
182 dumps_str(state.metadata),
183 dumps_str(state.active_tools),
184 dumps_str(state.active_skills),
185 state.system_prompt,
186 dumps_str(state.variables),
187 state.created_at.isoformat(),
188 state.updated_at.isoformat(),
189 state.checkpoint_id,
190 state.total_tokens,
191 state.total_cost,
192 state.turn_count,
193 state.parent_session_id,
194 state.branch_name,
195 )
196
197 async def load(self, session_id: str) -> SessionState | None:
198 """Return the session state for *session_id*, or ``None``.
199
200 Args:
201 session_id: The session to load.
202
203 Returns:
204 Deserialised ``SessionState`` or ``None``.
205 """
206 sql = "SELECT * FROM ai_sessions WHERE session_id = $1"
207 async with self._db.scoped_context():
208 conn = await self._db.get_scoped_connection()
209 row = await conn.fetchrow(sql, session_id)
210 if row is None:
211 return None
212 return _row_to_state(row)
213
214 async def delete(self, session_id: str) -> None:
215 """Remove a session from the database.
216
217 Args:
218 session_id: The session to delete.
219 """
220 async with self._db.scoped_context():
221 conn = await self._db.get_scoped_connection()
222 await conn.execute(
223 "DELETE FROM ai_sessions WHERE session_id = $1", session_id
224 )
225
226 async def list_sessions(self, user_id: str) -> list[SessionState]:
227 """List all sessions belonging to *user_id*.
228
229 Args:
230 user_id: User to filter by.
231
232 Returns:
233 All sessions for that user.
234 """
235 sql = "SELECT * FROM ai_sessions WHERE user_id = $1 ORDER BY created_at"
236 async with self._db.scoped_context():
237 conn = await self._db.get_scoped_connection()
238 rows = await conn.fetch(sql, user_id)
239 return [_row_to_state(r) for r in rows]
240
241 # ------------------------------------------------------------------
242 # Checkpoint CRUD
243 # ------------------------------------------------------------------
244
245 async def save_checkpoint(self, checkpoint: SessionCheckpoint) -> None:
246 """Persist an immutable checkpoint.
247
248 Args:
249 checkpoint: The checkpoint to save.
250 """
251 from lexigram.ai.session.stores.cache import _state_to_dict # local helper
252
253 sql = """
254 INSERT INTO ai_checkpoints
255 (checkpoint_id, session_id, state, created_at,
256 parent_checkpoint_id, metadata)
257 VALUES ($1,$2,$3,$4,$5,$6)
258 ON CONFLICT (checkpoint_id) DO NOTHING
259 """
260 async with self._db.scoped_context():
261 conn = await self._db.get_scoped_connection()
262 await conn.execute(
263 sql,
264 checkpoint.checkpoint_id,
265 checkpoint.session_id,
266 dumps_str(_state_to_dict(checkpoint.state)),
267 checkpoint.created_at.isoformat(),
268 checkpoint.parent_checkpoint_id,
269 dumps_str(checkpoint.metadata),
270 )
271
272 async def load_checkpoint(self, checkpoint_id: str) -> SessionCheckpoint | None:
273 """Return the checkpoint for *checkpoint_id*, or ``None``.
274
275 Args:
276 checkpoint_id: The checkpoint to load.
277
278 Returns:
279 Deserialised ``SessionCheckpoint`` or ``None``.
280 """
281 sql = "SELECT * FROM ai_checkpoints WHERE checkpoint_id = $1"
282 async with self._db.scoped_context():
283 conn = await self._db.get_scoped_connection()
284 row = await conn.fetchrow(sql, checkpoint_id)
285 if row is None:
286 return None
287 return self._row_to_checkpoint(row)
288
289 async def list_checkpoints(self, session_id: str) -> list[SessionCheckpoint]:
290 """List all checkpoints for *session_id*, oldest-first.
291
292 Args:
293 session_id: The session to query.
294
295 Returns:
296 Checkpoints in chronological order.
297 """
298 sql = "SELECT * FROM ai_checkpoints WHERE session_id = $1 ORDER BY created_at"
299 async with self._db.scoped_context():
300 conn = await self._db.get_scoped_connection()
301 rows = await conn.fetch(sql, session_id)
302 return [self._row_to_checkpoint(r) for r in rows]
303
304 async def delete_checkpoint(self, checkpoint_id: str) -> None:
305 """Remove a checkpoint from the database.
306
307 Args:
308 checkpoint_id: The checkpoint to delete.
309 """
310 async with self._db.scoped_context():
311 conn = await self._db.get_scoped_connection()
312 await conn.execute(
313 "DELETE FROM ai_checkpoints WHERE checkpoint_id = $1", checkpoint_id
314 )
315
316 # ------------------------------------------------------------------
317 # Internal helpers
318 # ------------------------------------------------------------------
319
320 @staticmethod
321 def _row_to_checkpoint(row: Any) -> SessionCheckpoint:
322 """Deserialise a DB row to a ``SessionCheckpoint``."""
323 from lexigram.ai.session.stores.cache import _dict_to_state # local helper
324
325 def _col(row: Any, idx: int, name: str) -> Any:
326 if isinstance(row, (list, tuple)):
327 return row[idx]
328 return row[name]
329
330 state_dict = loads(_col(row, 2, "state"))
331 return SessionCheckpoint(
332 checkpoint_id=_col(row, 0, "checkpoint_id"),
333 session_id=_col(row, 1, "session_id"),
334 state=_dict_to_state(state_dict),
335 created_at=datetime.fromisoformat(_col(row, 3, "created_at")),
336 parent_checkpoint_id=_col(row, 4, "parent_checkpoint_id"),
337 metadata=loads(_col(row, 5, "metadata")),
338 )
339
340
341__all__ = ["DatabaseSessionStore"]