2026-01-20 14:49:43 -08:00
|
|
|
# Copyright 2026 Google LLC
|
2025-11-08 21:19:04 -08:00
|
|
|
#
|
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
# you may not use this file except in compliance with the License.
|
|
|
|
|
# You may obtain a copy of the License at
|
|
|
|
|
#
|
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
#
|
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
# See the License for the specific language governing permissions and
|
|
|
|
|
# limitations under the License.
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
import copy
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import sqlite3
|
|
|
|
|
import time
|
|
|
|
|
from typing import Any
|
|
|
|
|
from typing import Optional
|
2026-01-08 16:13:37 -08:00
|
|
|
from urllib.parse import unquote
|
|
|
|
|
from urllib.parse import urlparse
|
2025-11-08 21:19:04 -08:00
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
import aiosqlite
|
|
|
|
|
from typing_extensions import override
|
|
|
|
|
|
|
|
|
|
from . import _session_util
|
|
|
|
|
from ..errors.already_exists_error import AlreadyExistsError
|
|
|
|
|
from ..events.event import Event
|
|
|
|
|
from .base_session_service import BaseSessionService
|
|
|
|
|
from .base_session_service import GetSessionConfig
|
|
|
|
|
from .base_session_service import ListSessionsResponse
|
|
|
|
|
from .session import Session
|
|
|
|
|
from .state import State
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("google_adk." + __name__)
|
|
|
|
|
|
|
|
|
|
PRAGMA_FOREIGN_KEYS = "PRAGMA foreign_keys = ON"
|
|
|
|
|
|
|
|
|
|
APP_STATES_TABLE_SCHEMA = """
|
|
|
|
|
CREATE TABLE IF NOT EXISTS app_states (
|
|
|
|
|
app_name TEXT PRIMARY KEY,
|
|
|
|
|
state TEXT NOT NULL,
|
|
|
|
|
update_time REAL NOT NULL
|
|
|
|
|
);
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
USER_STATES_TABLE_SCHEMA = """
|
|
|
|
|
CREATE TABLE IF NOT EXISTS user_states (
|
|
|
|
|
app_name TEXT NOT NULL,
|
|
|
|
|
user_id TEXT NOT NULL,
|
|
|
|
|
state TEXT NOT NULL,
|
|
|
|
|
update_time REAL NOT NULL,
|
|
|
|
|
PRIMARY KEY (app_name, user_id)
|
|
|
|
|
);
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
SESSIONS_TABLE_SCHEMA = """
|
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
|
|
|
app_name TEXT NOT NULL,
|
|
|
|
|
user_id TEXT NOT NULL,
|
|
|
|
|
id TEXT NOT NULL,
|
|
|
|
|
state TEXT NOT NULL,
|
|
|
|
|
create_time REAL NOT NULL,
|
|
|
|
|
update_time REAL NOT NULL,
|
|
|
|
|
PRIMARY KEY (app_name, user_id, id)
|
|
|
|
|
);
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
EVENTS_TABLE_SCHEMA = """
|
|
|
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
|
|
|
id TEXT NOT NULL,
|
|
|
|
|
app_name TEXT NOT NULL,
|
|
|
|
|
user_id TEXT NOT NULL,
|
|
|
|
|
session_id TEXT NOT NULL,
|
|
|
|
|
invocation_id TEXT NOT NULL,
|
|
|
|
|
timestamp REAL NOT NULL,
|
|
|
|
|
event_data TEXT NOT NULL,
|
|
|
|
|
PRIMARY KEY (app_name, user_id, session_id, id),
|
|
|
|
|
FOREIGN KEY (app_name, user_id, session_id) REFERENCES sessions(app_name, user_id, id) ON DELETE CASCADE
|
|
|
|
|
);
|
|
|
|
|
"""
|
|
|
|
|
CREATE_SCHEMA_SQL = "\n".join([
|
|
|
|
|
APP_STATES_TABLE_SCHEMA,
|
|
|
|
|
USER_STATES_TABLE_SCHEMA,
|
|
|
|
|
SESSIONS_TABLE_SCHEMA,
|
|
|
|
|
EVENTS_TABLE_SCHEMA,
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
2026-01-08 16:13:37 -08:00
|
|
|
def _parse_db_path(db_path: str) -> tuple[str, str, bool]:
|
|
|
|
|
"""Normalizes a SQLite db path from a URL or filesystem path.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A tuple of:
|
|
|
|
|
- filesystem path (for `os.path.exists` and user-facing messages)
|
|
|
|
|
- value to pass to sqlite/aiosqlite connect
|
|
|
|
|
- whether to pass `uri=True` to sqlite/aiosqlite connect
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
When a SQLAlchemy-style SQLite URL is provided, this follows SQLAlchemy's
|
|
|
|
|
conventions:
|
|
|
|
|
- `sqlite:///relative.db` is a path relative to the current working dir.
|
|
|
|
|
- `sqlite:////absolute.db` is an absolute filesystem path.
|
|
|
|
|
"""
|
|
|
|
|
if not db_path.startswith(("sqlite:", "sqlite+aiosqlite:")):
|
|
|
|
|
return db_path, db_path, False
|
|
|
|
|
|
|
|
|
|
parsed = urlparse(db_path)
|
|
|
|
|
raw_path = unquote(parsed.path)
|
|
|
|
|
if not raw_path:
|
|
|
|
|
return db_path, db_path, False
|
|
|
|
|
|
|
|
|
|
normalized_path = raw_path
|
|
|
|
|
if normalized_path.startswith("//"):
|
|
|
|
|
normalized_path = normalized_path[1:]
|
|
|
|
|
elif normalized_path.startswith("/"):
|
|
|
|
|
normalized_path = normalized_path[1:]
|
|
|
|
|
|
|
|
|
|
if parsed.query:
|
|
|
|
|
# sqlite3 only treats the filename as a URI when it starts with `file:`.
|
|
|
|
|
return normalized_path, f"file:{normalized_path}?{parsed.query}", True
|
|
|
|
|
|
|
|
|
|
return normalized_path, normalized_path, False
|
|
|
|
|
|
|
|
|
|
|
2025-11-08 21:19:04 -08:00
|
|
|
class SqliteSessionService(BaseSessionService):
|
|
|
|
|
"""A session service that uses an SQLite database for storage via aiosqlite.
|
|
|
|
|
|
|
|
|
|
Event data is stored as JSON to allow for schema flexibility as event
|
|
|
|
|
fields evolve.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, db_path: str):
|
|
|
|
|
"""Initializes the SQLite session service with a database path."""
|
2026-01-08 16:13:37 -08:00
|
|
|
self._db_path, self._db_connect_path, self._db_connect_uri = _parse_db_path(
|
|
|
|
|
db_path
|
|
|
|
|
)
|
2025-11-08 21:19:04 -08:00
|
|
|
|
|
|
|
|
if self._is_migration_needed():
|
|
|
|
|
raise RuntimeError(
|
2026-01-08 16:13:37 -08:00
|
|
|
f"Database {self._db_path} seems to use an old schema."
|
2025-11-08 21:19:04 -08:00
|
|
|
" Please run the migration command to"
|
|
|
|
|
" migrate it to the new schema. Example: `python -m"
|
2025-12-15 13:38:13 -08:00
|
|
|
" google.adk.sessions.migration.migrate_from_sqlalchemy_sqlite"
|
2026-01-08 16:13:37 -08:00
|
|
|
f" --source_db_path {self._db_path} --dest_db_path"
|
|
|
|
|
f" {self._db_path}.new` then backup {self._db_path} and rename"
|
|
|
|
|
f" {self._db_path}.new to {self._db_path}."
|
2025-11-08 21:19:04 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
async def create_session(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
app_name: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
state: Optional[dict[str, Any]] = None,
|
|
|
|
|
session_id: Optional[str] = None,
|
|
|
|
|
) -> Session:
|
|
|
|
|
if session_id:
|
|
|
|
|
session_id = session_id.strip()
|
|
|
|
|
if not session_id:
|
|
|
|
|
session_id = str(uuid.uuid4())
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
|
|
|
|
async with self._get_db_connection() as db:
|
|
|
|
|
# Check if session_id already exists
|
|
|
|
|
async with db.execute(
|
|
|
|
|
"SELECT 1 FROM sessions WHERE app_name=? AND user_id=? AND id=?",
|
|
|
|
|
(app_name, user_id, session_id),
|
|
|
|
|
) as cursor:
|
|
|
|
|
if await cursor.fetchone():
|
|
|
|
|
raise AlreadyExistsError(
|
|
|
|
|
f"Session with id {session_id} already exists."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Extract state deltas
|
|
|
|
|
state_deltas = _session_util.extract_state_delta(state)
|
|
|
|
|
app_state_delta = state_deltas["app"]
|
|
|
|
|
user_state_delta = state_deltas["user"]
|
|
|
|
|
session_state = state_deltas["session"]
|
|
|
|
|
|
|
|
|
|
# Apply state delta and update/insert states atomically
|
|
|
|
|
if app_state_delta:
|
|
|
|
|
await self._upsert_app_state(db, app_name, app_state_delta, now)
|
|
|
|
|
if user_state_delta:
|
|
|
|
|
await self._upsert_user_state(
|
|
|
|
|
db, app_name, user_id, user_state_delta, now
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Fetch current state after upserts
|
|
|
|
|
storage_app_state = await self._get_app_state(db, app_name)
|
|
|
|
|
storage_user_state = await self._get_user_state(db, app_name, user_id)
|
|
|
|
|
|
|
|
|
|
# Store the session
|
|
|
|
|
await db.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO sessions (app_name, user_id, id, state, create_time, update_time)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
app_name,
|
|
|
|
|
user_id,
|
|
|
|
|
session_id,
|
|
|
|
|
json.dumps(session_state),
|
|
|
|
|
now,
|
|
|
|
|
now,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
# Merge states for response
|
|
|
|
|
merged_state = _merge_state(
|
|
|
|
|
storage_app_state, storage_user_state, session_state
|
|
|
|
|
)
|
|
|
|
|
return Session(
|
|
|
|
|
app_name=app_name,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
id=session_id,
|
|
|
|
|
state=merged_state,
|
|
|
|
|
events=[],
|
|
|
|
|
last_update_time=now,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
async def get_session(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
app_name: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
session_id: str,
|
|
|
|
|
config: Optional[GetSessionConfig] = None,
|
|
|
|
|
) -> Optional[Session]:
|
|
|
|
|
async with self._get_db_connection() as db:
|
|
|
|
|
async with db.execute(
|
|
|
|
|
"SELECT state, update_time FROM sessions WHERE app_name=? AND"
|
|
|
|
|
" user_id=? AND id=?",
|
|
|
|
|
(app_name, user_id, session_id),
|
|
|
|
|
) as cursor:
|
|
|
|
|
session_row = await cursor.fetchone()
|
|
|
|
|
if session_row is None:
|
|
|
|
|
return None
|
|
|
|
|
session_state = json.loads(session_row["state"])
|
|
|
|
|
last_update_time = session_row["update_time"]
|
|
|
|
|
|
|
|
|
|
# Build events query
|
|
|
|
|
query_parts = [
|
|
|
|
|
"SELECT event_data FROM events",
|
|
|
|
|
"WHERE app_name=? AND user_id=? AND session_id=?",
|
|
|
|
|
]
|
|
|
|
|
params: list[Any] = [app_name, user_id, session_id]
|
|
|
|
|
|
|
|
|
|
if config and config.after_timestamp:
|
|
|
|
|
query_parts.append("AND timestamp >= ?")
|
|
|
|
|
params.append(config.after_timestamp)
|
|
|
|
|
|
|
|
|
|
query_parts.append("ORDER BY timestamp DESC")
|
|
|
|
|
|
|
|
|
|
if config and config.num_recent_events:
|
|
|
|
|
query_parts.append("LIMIT ?")
|
|
|
|
|
params.append(config.num_recent_events)
|
|
|
|
|
|
|
|
|
|
event_rows = await db.execute_fetchall(" ".join(query_parts), params)
|
|
|
|
|
storage_events_data = [row["event_data"] for row in event_rows]
|
|
|
|
|
|
|
|
|
|
# Fetch states from storage
|
|
|
|
|
app_state = await self._get_app_state(db, app_name)
|
|
|
|
|
user_state = await self._get_user_state(db, app_name, user_id)
|
|
|
|
|
|
|
|
|
|
# Merge states
|
|
|
|
|
merged_state = _merge_state(app_state, user_state, session_state)
|
|
|
|
|
|
|
|
|
|
# Deserialize events and reverse to chronological order
|
|
|
|
|
events = [
|
|
|
|
|
Event.model_validate_json(event_data)
|
|
|
|
|
for event_data in reversed(storage_events_data)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return Session(
|
|
|
|
|
app_name=app_name,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
id=session_id,
|
|
|
|
|
state=merged_state,
|
|
|
|
|
events=events,
|
|
|
|
|
last_update_time=last_update_time,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
async def list_sessions(
|
|
|
|
|
self, *, app_name: str, user_id: Optional[str] = None
|
|
|
|
|
) -> ListSessionsResponse:
|
|
|
|
|
sessions_list = []
|
|
|
|
|
async with self._get_db_connection() as db:
|
|
|
|
|
# Fetch sessions
|
|
|
|
|
if user_id:
|
|
|
|
|
session_rows = await db.execute_fetchall(
|
|
|
|
|
"SELECT id, user_id, state, update_time FROM sessions WHERE"
|
|
|
|
|
" app_name=? AND user_id=?",
|
|
|
|
|
(app_name, user_id),
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
session_rows = await db.execute_fetchall(
|
|
|
|
|
"SELECT id, user_id, state, update_time FROM sessions WHERE"
|
|
|
|
|
" app_name=?",
|
|
|
|
|
(app_name,),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Fetch app state
|
|
|
|
|
app_state = await self._get_app_state(db, app_name)
|
|
|
|
|
|
|
|
|
|
# Fetch user states
|
|
|
|
|
user_states_map = {}
|
|
|
|
|
if user_id:
|
|
|
|
|
user_state = await self._get_user_state(db, app_name, user_id)
|
|
|
|
|
if user_state:
|
|
|
|
|
user_states_map[user_id] = user_state
|
|
|
|
|
else:
|
|
|
|
|
async with db.execute(
|
|
|
|
|
"SELECT user_id, state FROM user_states WHERE app_name=?",
|
|
|
|
|
(app_name,),
|
|
|
|
|
) as cursor:
|
|
|
|
|
async for row in cursor:
|
|
|
|
|
user_states_map[row["user_id"]] = json.loads(row["state"])
|
|
|
|
|
|
|
|
|
|
# Build session list
|
|
|
|
|
for row in session_rows:
|
|
|
|
|
session_user_id = row["user_id"]
|
|
|
|
|
session_state = json.loads(row["state"])
|
|
|
|
|
user_state = user_states_map.get(session_user_id, {})
|
|
|
|
|
merged_state = _merge_state(app_state, user_state, session_state)
|
|
|
|
|
sessions_list.append(
|
|
|
|
|
Session(
|
|
|
|
|
app_name=app_name,
|
|
|
|
|
user_id=session_user_id,
|
|
|
|
|
id=row["id"],
|
|
|
|
|
state=merged_state,
|
|
|
|
|
events=[],
|
|
|
|
|
last_update_time=row["update_time"],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return ListSessionsResponse(sessions=sessions_list)
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
async def delete_session(
|
|
|
|
|
self, *, app_name: str, user_id: str, session_id: str
|
|
|
|
|
) -> None:
|
|
|
|
|
async with self._get_db_connection() as db:
|
|
|
|
|
await db.execute(
|
|
|
|
|
"DELETE FROM sessions WHERE app_name=? AND user_id=? AND id=?",
|
|
|
|
|
(app_name, user_id, session_id),
|
|
|
|
|
)
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
async def append_event(self, session: Session, event: Event) -> Event:
|
|
|
|
|
if event.partial:
|
|
|
|
|
return event
|
|
|
|
|
|
|
|
|
|
# Trim temp state before persisting
|
|
|
|
|
event = self._trim_temp_delta_state(event)
|
2025-11-20 18:26:31 -08:00
|
|
|
event_timestamp = event.timestamp
|
2025-11-08 21:19:04 -08:00
|
|
|
|
|
|
|
|
async with self._get_db_connection() as db:
|
|
|
|
|
# Check for stale session
|
|
|
|
|
async with db.execute(
|
|
|
|
|
"SELECT update_time FROM sessions WHERE app_name=? AND user_id=? AND"
|
|
|
|
|
" id=?",
|
|
|
|
|
(session.app_name, session.user_id, session.id),
|
|
|
|
|
) as cursor:
|
|
|
|
|
row = await cursor.fetchone()
|
|
|
|
|
if row is None:
|
|
|
|
|
raise ValueError(f"Session {session.id} not found.")
|
|
|
|
|
storage_update_time = row["update_time"]
|
|
|
|
|
if storage_update_time > session.last_update_time:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"The last_update_time provided in the session object is"
|
|
|
|
|
" earlier than the update_time in storage."
|
|
|
|
|
" Please check if it is a stale session."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Apply state delta if present
|
|
|
|
|
has_session_state_delta = False
|
|
|
|
|
if event.actions and event.actions.state_delta:
|
|
|
|
|
state_deltas = _session_util.extract_state_delta(
|
|
|
|
|
event.actions.state_delta
|
|
|
|
|
)
|
|
|
|
|
app_state_delta = state_deltas["app"]
|
|
|
|
|
user_state_delta = state_deltas["user"]
|
|
|
|
|
session_state_delta = state_deltas["session"]
|
|
|
|
|
|
|
|
|
|
if app_state_delta:
|
|
|
|
|
await self._upsert_app_state(
|
2025-11-20 18:26:31 -08:00
|
|
|
db, session.app_name, app_state_delta, event_timestamp
|
2025-11-08 21:19:04 -08:00
|
|
|
)
|
|
|
|
|
if user_state_delta:
|
|
|
|
|
await self._upsert_user_state(
|
2025-11-20 18:26:31 -08:00
|
|
|
db,
|
|
|
|
|
session.app_name,
|
|
|
|
|
session.user_id,
|
|
|
|
|
user_state_delta,
|
|
|
|
|
event_timestamp,
|
2025-11-08 21:19:04 -08:00
|
|
|
)
|
|
|
|
|
if session_state_delta:
|
|
|
|
|
await self._update_session_state_in_db(
|
|
|
|
|
db,
|
|
|
|
|
session.app_name,
|
|
|
|
|
session.user_id,
|
|
|
|
|
session.id,
|
|
|
|
|
session_state_delta,
|
2025-11-20 18:26:31 -08:00
|
|
|
event_timestamp,
|
2025-11-08 21:19:04 -08:00
|
|
|
)
|
|
|
|
|
has_session_state_delta = True
|
|
|
|
|
|
|
|
|
|
# Insert event and update session timestamp
|
|
|
|
|
await db.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO events (id, app_name, user_id, session_id, invocation_id, timestamp, event_data)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
event.id,
|
|
|
|
|
session.app_name,
|
|
|
|
|
session.user_id,
|
|
|
|
|
session.id,
|
|
|
|
|
event.invocation_id,
|
|
|
|
|
event.timestamp,
|
|
|
|
|
event.model_dump_json(exclude_none=True),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
if not has_session_state_delta:
|
|
|
|
|
await db.execute(
|
|
|
|
|
"UPDATE sessions SET update_time=? WHERE app_name=? AND user_id=?"
|
|
|
|
|
" AND id=?",
|
2025-11-20 18:26:31 -08:00
|
|
|
(
|
|
|
|
|
event_timestamp,
|
|
|
|
|
session.app_name,
|
|
|
|
|
session.user_id,
|
|
|
|
|
session.id,
|
|
|
|
|
),
|
2025-11-08 21:19:04 -08:00
|
|
|
)
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
2025-11-20 18:26:31 -08:00
|
|
|
# Update timestamp based on event time
|
|
|
|
|
session.last_update_time = event_timestamp
|
2025-11-08 21:19:04 -08:00
|
|
|
|
|
|
|
|
# Also update the in-memory session
|
|
|
|
|
await super().append_event(session=session, event=event)
|
|
|
|
|
return event
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def _get_db_connection(self):
|
|
|
|
|
"""Connects to the db and performs initial setup."""
|
2026-01-08 16:13:37 -08:00
|
|
|
async with aiosqlite.connect(
|
|
|
|
|
self._db_connect_path, uri=self._db_connect_uri
|
|
|
|
|
) as db:
|
2025-11-08 21:19:04 -08:00
|
|
|
db.row_factory = aiosqlite.Row
|
|
|
|
|
await db.execute(PRAGMA_FOREIGN_KEYS)
|
|
|
|
|
await db.executescript(CREATE_SCHEMA_SQL)
|
|
|
|
|
yield db
|
|
|
|
|
|
|
|
|
|
async def _get_state(
|
|
|
|
|
self, db: aiosqlite.Connection, query: str, params: tuple
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Fetches and deserializes a JSON state column from a single row."""
|
|
|
|
|
async with db.execute(query, params) as cursor:
|
|
|
|
|
row = await cursor.fetchone()
|
|
|
|
|
return json.loads(row["state"]) if row else {}
|
|
|
|
|
|
|
|
|
|
async def _get_app_state(
|
|
|
|
|
self, db: aiosqlite.Connection, app_name: str
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
return await self._get_state(
|
|
|
|
|
db, "SELECT state FROM app_states WHERE app_name=?", (app_name,)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _get_user_state(
|
|
|
|
|
self, db: aiosqlite.Connection, app_name: str, user_id: str
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
return await self._get_state(
|
|
|
|
|
db,
|
|
|
|
|
"SELECT state FROM user_states WHERE app_name=? AND user_id=?",
|
|
|
|
|
(app_name, user_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _get_session_state(
|
|
|
|
|
self,
|
|
|
|
|
db: aiosqlite.Connection,
|
|
|
|
|
app_name: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
session_id: str,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
return await self._get_state(
|
|
|
|
|
db,
|
|
|
|
|
"SELECT state FROM sessions WHERE app_name=? AND user_id=? AND id=?",
|
|
|
|
|
(app_name, user_id, session_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _upsert_app_state(
|
|
|
|
|
self, db: aiosqlite.Connection, app_name: str, delta: dict, now: float
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Atomically inserts or updates app state using json_patch."""
|
|
|
|
|
await db.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO app_states (app_name, state, update_time) VALUES (?, ?, ?)
|
|
|
|
|
ON CONFLICT(app_name) DO UPDATE SET state=json_patch(state, excluded.state), update_time=excluded.update_time
|
|
|
|
|
""",
|
|
|
|
|
(app_name, json.dumps(delta), now),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _upsert_user_state(
|
|
|
|
|
self,
|
|
|
|
|
db: aiosqlite.Connection,
|
|
|
|
|
app_name: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
delta: dict,
|
|
|
|
|
now: float,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Atomically inserts or updates user state using json_patch."""
|
|
|
|
|
await db.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO user_states (app_name, user_id, state, update_time) VALUES (?, ?, ?, ?)
|
|
|
|
|
ON CONFLICT(app_name, user_id) DO UPDATE SET state=json_patch(state, excluded.state), update_time=excluded.update_time
|
|
|
|
|
""",
|
|
|
|
|
(app_name, user_id, json.dumps(delta), now),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _update_session_state_in_db(
|
|
|
|
|
self,
|
|
|
|
|
db: aiosqlite.Connection,
|
|
|
|
|
app_name: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
session_id: str,
|
|
|
|
|
delta: dict,
|
|
|
|
|
now: float,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Atomically updates session state using json_patch."""
|
|
|
|
|
await db.execute(
|
|
|
|
|
"UPDATE sessions SET state=json_patch(state, ?), update_time=? WHERE"
|
|
|
|
|
" app_name=? AND user_id=? AND id=?",
|
|
|
|
|
(
|
|
|
|
|
json.dumps(delta),
|
|
|
|
|
now,
|
|
|
|
|
app_name,
|
|
|
|
|
user_id,
|
|
|
|
|
session_id,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _is_migration_needed(self) -> bool:
|
|
|
|
|
"""Checks if migration to new schema is needed."""
|
|
|
|
|
if not os.path.exists(self._db_path):
|
|
|
|
|
return False
|
|
|
|
|
try:
|
2026-01-08 16:13:37 -08:00
|
|
|
with sqlite3.connect(
|
|
|
|
|
self._db_connect_path, uri=self._db_connect_uri
|
|
|
|
|
) as conn:
|
2025-11-08 21:19:04 -08:00
|
|
|
cursor = conn.cursor()
|
|
|
|
|
# Check if events table exists
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' and name='events'"
|
|
|
|
|
)
|
|
|
|
|
if not cursor.fetchone():
|
|
|
|
|
return False # No events table, so no migration needed.
|
|
|
|
|
|
|
|
|
|
# If events table exists, check for event_data column
|
|
|
|
|
cursor.execute("PRAGMA table_info(events)")
|
|
|
|
|
columns = [row[1] for row in cursor.fetchall()]
|
|
|
|
|
if "event_data" in columns:
|
|
|
|
|
return False # New schema: event_data column exists.
|
|
|
|
|
else:
|
|
|
|
|
return (
|
|
|
|
|
True # Old schema: events table exists, but no event_data column.
|
|
|
|
|
)
|
|
|
|
|
except sqlite3.Error as e:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Error accessing database {self._db_path}: {e}"
|
|
|
|
|
) from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _merge_state(app_state, user_state, session_state):
|
|
|
|
|
"""Merges app, user, and session states into a single dictionary."""
|
|
|
|
|
merged_state = copy.deepcopy(session_state)
|
|
|
|
|
for key, value in app_state.items():
|
|
|
|
|
merged_state[State.APP_PREFIX + key] = value
|
|
|
|
|
for key, value in user_state.items():
|
|
|
|
|
merged_state[State.USER_PREFIX + key] = value
|
|
|
|
|
return merged_state
|