Source code for sqlspec.adapters.asyncmy.adk.store

"""AsyncMy ADK store for Google Agent Development Kit session/event storage."""

import re
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final, Literal, cast

from typing_extensions import NotRequired

from sqlspec.adapters.asyncmy._typing import AsyncmyProgrammingError
from sqlspec.adapters.asyncmy.core import resolve_rowcount
from sqlspec.config import ADKConfig
from sqlspec.extensions.adk import BaseAsyncADKStore, StoredEvent, StoredSession, normalize_session_list_options
from sqlspec.extensions.adk.memory.store import BaseAsyncADKMemoryStore
from sqlspec.utils.serializers import from_json, to_json

if TYPE_CHECKING:
    from collections.abc import Sequence
    from datetime import datetime, timedelta

    from sqlspec.adapters.asyncmy.config import AsyncmyConfig
    from sqlspec.extensions.adk import SessionOrderBy, StoredMemory


__all__ = ("AsyncmyADKConfig", "AsyncmyADKMemoryStore", "AsyncmyADKStore")

MYSQL_TABLE_NOT_FOUND_ERROR: Final = 1146


[docs] class AsyncmyADKConfig(ADKConfig): """asyncmy-specific ADK extension settings. Use these keys inside ``extension_config["adk"]`` with the asyncmy ADK store. """ enable_event_generated_columns: NotRequired[bool] """Create MySQL generated columns and indexes for common ADK event JSON paths.""" enable_covering_indexes: NotRequired[bool] """Add hot-path payload columns to MySQL ADK event replay indexes.""" session_table_options: NotRequired[str] """Raw MySQL table options appended to the ADK session table.""" events_table_options: NotRequired[str] """Raw MySQL table options appended to the ADK events table.""" app_state_table_options: NotRequired[str] """Raw MySQL table options appended to the ADK app state table.""" user_state_table_options: NotRequired[str] """Raw MySQL table options appended to the ADK user state table.""" memory_table_options: NotRequired[str] """Raw MySQL table options appended to the ADK memory table."""
class AsyncmyADKStore(BaseAsyncADKStore["AsyncmyConfig"]): """MySQL/MariaDB ADK store using AsyncMy driver.""" __slots__ = () def __init__(self, config: "AsyncmyConfig") -> None: """Initialize AsyncMy ADK store.""" super().__init__(config) async def create_tables(self) -> None: """Create all ADK session tables if they don't exist.""" if not self.create_schema_enabled: await self.reconcile_schema() return async with self._config.provide_session() as driver: await driver.execute_script(await self._sessions_table_ddl()) await driver.execute_script(await self._events_table_ddl()) await driver.execute_script(await self._app_states_table_ddl()) await driver.execute_script(await self._user_states_table_ddl()) await driver.execute_script(await self._metadata_table_ddl()) async def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new session.""" params: tuple[Any, ...] if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, {self._owner_id_column_name}, state, create_time, update_time) VALUES (%s, %s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ params = (session_id, app_name, user_id, owner_id, to_json(state)) else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (%s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ params = (session_id, app_name, user_id, to_json(state)) async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, params) await conn.commit() result = await self.get_session(app_name, user_id, session_id) if result is None: msg = "Failed to fetch created session" raise RuntimeError(msg) return result async def get_session( self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None ) -> "StoredSession | None": """Get session by scoped identifiers.""" try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: if renew_for is not None and self._calculate_expires_at(renew_for) is not None: await cursor.execute( f""" UPDATE {self._session_table} SET update_time = UTC_TIMESTAMP(6) WHERE app_name = %s AND user_id = %s AND id = %s """, (app_name, user_id, session_id), ) await conn.commit() await cursor.execute( f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {self._session_table} WHERE app_name = %s AND user_id = %s AND id = %s """, (app_name, user_id, session_id), ) row = await cursor.fetchone() return _session_record_from_row(row) if row is not None else None except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return None raise async def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: """Update session state.""" sql = f""" UPDATE {self._session_table} SET state = %s, update_time = UTC_TIMESTAMP(6) WHERE app_name = %s AND user_id = %s AND id = %s """ async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, (to_json(state), app_name, user_id, session_id)) await conn.commit() async def list_sessions( self, app_name: str, user_id: "str | None" = None, *, order_by: "SessionOrderBy" = "update_time", descending: bool = True, limit: "int | None" = None, offset: "int | None" = None, ) -> "list[StoredSession]": """List sessions for an app, optionally filtered by user.""" column, direction, page_limit, page_offset = normalize_session_list_options(order_by, descending, limit, offset) if page_limit == 0: return [] sql, params = _session_list_query( self._session_table, app_name, user_id, column, direction, page_limit, page_offset ) try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, params) rows = await cursor.fetchall() return [_session_record_from_row(row) for row in rows] except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return [] raise async def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: """Delete session and all associated events.""" sql = f"DELETE FROM {self._session_table} WHERE app_name = %s AND user_id = %s AND id = %s" async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, (app_name, user_id, session_id)) await conn.commit() async def append_event(self, event_record: StoredEvent) -> None: """Append an event to a session.""" sql = f""" INSERT INTO {self._events_table} ( id, app_name, user_id, session_id, invocation_id, timestamp, event_data ) VALUES (%s, %s, %s, %s, %s, %s, %s) """ async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, _event_insert_params(event_record)) await conn.commit() async def append_event_and_update_state( self, event_record: StoredEvent, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]", *, app_state: "dict[str, Any] | None" = None, user_state: "dict[str, Any] | None" = None, ) -> StoredSession: """Atomically append an event and update session + scoped state.""" insert_sql = f""" INSERT INTO {self._events_table} ( id, app_name, user_id, session_id, invocation_id, timestamp, event_data ) VALUES (%s, %s, %s, %s, %s, %s, %s) """ update_sql = f""" UPDATE {self._session_table} SET state = %s, update_time = UTC_TIMESTAMP(6) WHERE app_name = %s AND user_id = %s AND id = %s """ select_sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {self._session_table} WHERE app_name = %s AND user_id = %s AND id = %s """ async with self._config.provide_connection() as conn, conn.cursor() as cursor: try: await cursor.execute(update_sql, (to_json(state), app_name, user_id, session_id)) await cursor.execute(select_sql, (app_name, user_id, session_id)) row = await cursor.fetchone() if row is None: _raise_session_not_found(session_id) await cursor.execute( insert_sql, ( event_record["id"], app_name, user_id, session_id, event_record["invocation_id"], event_record["timestamp"], _json_for_storage(event_record["event_data"]), ), ) if app_state is not None: await cursor.execute( _mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(app_state)) ) if user_state is not None: await cursor.execute( _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(user_state)) ) await conn.commit() except Exception: await conn.rollback() raise return _session_record_from_row(row) async def get_events( self, app_name: str, user_id: str, session_id: str, after_timestamp: "datetime | None" = None, limit: "int | None" = None, ) -> "list[StoredEvent]": """Get events for a session.""" if limit == 0: return [] where_clauses = ["app_name = %s", "user_id = %s", "session_id = %s"] params: list[Any] = [app_name, user_id, session_id] if after_timestamp is not None: where_clauses.append("timestamp > %s") params.append(after_timestamp) limit_clause = "" if limit is not None: limit_clause = " LIMIT %s" params.append(limit) sql = f""" SELECT id, app_name, user_id, session_id, invocation_id, timestamp, event_data FROM {self._events_table} WHERE {" AND ".join(where_clauses)} ORDER BY timestamp ASC{limit_clause} """ try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, params) rows = await cursor.fetchall() return [_event_record_from_row(row) for row in rows] except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return [] raise async def delete_expired_events(self, before: "datetime", app_name: "str | None" = None) -> int: """Delete events older than the given timestamp.""" sql = f"DELETE FROM {self._events_table} WHERE timestamp < %s" params: list[Any] = [before] if app_name is not None: sql += " AND app_name = %s" params.append(app_name) try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, tuple(params)) await conn.commit() return resolve_rowcount(cursor) except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return 0 raise async def delete_idle_sessions(self, updated_before: "datetime", app_name: "str | None" = None) -> int: """Delete sessions whose update_time predates the threshold.""" sql = f"DELETE FROM {self._session_table} WHERE update_time < %s" params: list[Any] = [updated_before] if app_name is not None: sql += " AND app_name = %s" params.append(app_name) try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, tuple(params)) await conn.commit() return resolve_rowcount(cursor) except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return 0 raise async def delete_idle_user_states(self, updated_before: "datetime", app_name: "str | None" = None) -> int: """Delete user state rows whose update_time predates the threshold.""" sql = f"DELETE FROM {self._user_state_table} WHERE update_time < %s" params: list[Any] = [updated_before] if app_name is not None: sql += " AND app_name = %s" params.append(app_name) try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, tuple(params)) await conn.commit() return resolve_rowcount(cursor) except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return 0 raise async def get_app_state(self, app_name: str) -> "dict[str, Any] | None": """Return app-scoped state for an application.""" sql = f"SELECT state FROM {self._app_state_table} WHERE app_name = %s" try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, (app_name,)) row = await cursor.fetchone() return _json_dict(row[0]) if row is not None else None except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return None raise async def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": """Return user-scoped state for an application user.""" sql = f"SELECT state FROM {self._user_state_table} WHERE app_name = %s AND user_id = %s" try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, (app_name, user_id)) row = await cursor.fetchone() return _json_dict(row[0]) if row is not None else None except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return None raise async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state for an application.""" async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(_mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(state))) await conn.commit() async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state for an application user.""" async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute( _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(state)) ) await conn.commit() async def get_metadata(self, key: str) -> "str | None": """Return a value from the ADK internal metadata table.""" sql = f"SELECT value FROM {self._metadata_table} WHERE `key` = %s" try: async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, (key,)) row = await cursor.fetchone() return str(row[0]) if row is not None else None except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] if _is_mysql_table_missing(exc): return None raise async def set_metadata(self, key: str, value: str) -> None: """Set a value in the ADK internal metadata table.""" async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(_mysql_upsert_metadata_sql(self._metadata_table), (key, value)) await conn.commit() async def _sessions_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for sessions.""" adk_config = _adk_config(self._config) table_options = _mysql_table_options(adk_config, "session_table_options") return _mysql_sessions_ddl(self._session_table, self._owner_id_column_ddl, table_options) async def _events_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for events.""" return _mysql_events_ddl(self._events_table, self._session_table, _adk_config(self._config)) async def _app_states_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for app-scoped state.""" adk_config = _adk_config(self._config) table_options = _mysql_table_options(adk_config, "app_state_table_options") return _mysql_app_state_ddl(self._app_state_table, table_options) async def _user_states_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for user-scoped state.""" adk_config = _adk_config(self._config) table_options = _mysql_table_options(adk_config, "user_state_table_options") return _mysql_user_state_ddl(self._user_state_table, table_options) async def _metadata_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for ADK metadata.""" return _mysql_metadata_ddl(self._metadata_table) def _drop_app_states_table_sql(self) -> str: """Get MySQL DROP TABLE SQL for app-scoped state.""" return f"DROP TABLE IF EXISTS {self._app_state_table}" def _drop_user_states_table_sql(self) -> str: """Get MySQL DROP TABLE SQL for user-scoped state.""" return f"DROP TABLE IF EXISTS {self._user_state_table}" def _drop_metadata_table_sql(self) -> str: """Get MySQL DROP TABLE SQL for ADK metadata.""" return f"DROP TABLE IF EXISTS {self._metadata_table}" def _drop_tables_sql(self) -> "list[str]": """Get MySQL DROP TABLE SQL statements.""" return [ self._drop_metadata_table_sql(), self._drop_user_states_table_sql(), self._drop_app_states_table_sql(), f"DROP TABLE IF EXISTS {self._events_table}", f"DROP TABLE IF EXISTS {self._session_table}", ] class AsyncmyADKMemoryStore(BaseAsyncADKMemoryStore["AsyncmyConfig"]): """MySQL/MariaDB ADK memory store using AsyncMy driver.""" __slots__ = () def __init__(self, config: "AsyncmyConfig") -> None: """Initialize AsyncMy memory store.""" super().__init__(config) async def create_tables(self) -> None: """Create the memory table and indexes if they don't exist.""" if not self.create_schema_enabled: await self.reconcile_schema() return if not self._enabled: return async with self._config.provide_session() as driver: await driver.execute_script(await self._memory_table_ddl()) async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) if not entries: return 0 inserted_count = 0 if self._owner_id_column_name: sql = f""" INSERT IGNORE INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author, {self._owner_id_column_name}, timestamp, content_json, content_text, metadata_json, inserted_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ else: sql = f""" INSERT IGNORE INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ async with self._config.provide_connection() as conn: async with conn.cursor() as cursor: for entry in entries: params: tuple[Any, ...] if self._owner_id_column_name: params = ( entry["id"], entry["session_id"], entry["app_name"], entry["user_id"], entry.get("scope", "user"), entry["event_id"], entry.get("author"), owner_id, entry["timestamp"], to_json(entry["content_json"]), entry["content_text"], to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, entry["inserted_at"], ) else: params = ( entry["id"], entry["session_id"], entry["app_name"], entry["user_id"], entry.get("scope", "user"), entry["event_id"], entry.get("author"), entry["timestamp"], to_json(entry["content_json"]), entry["content_text"], to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, entry["inserted_at"], ) await cursor.execute(sql, params) inserted_count += resolve_rowcount(cursor) await conn.commit() return inserted_count async def search_entries( self, query: str, app_name: str, user_id: str, limit: "int | None" = None, scope_filter: Literal["all", "user", "app"] = "all", embedding: "Sequence[float] | None" = None, ) -> "list[StoredMemory]": """Search memory entries by text query.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) if not query: return [] limit_value = limit or self._max_results where_scope, scope_params = _build_mysql_scope_where(app_name, user_id, scope_filter) if self._use_fts: sql = f""" SELECT * FROM {self._memory_table} WHERE {where_scope} AND MATCH(content_text) AGAINST (%s IN NATURAL LANGUAGE MODE) ORDER BY timestamp DESC LIMIT %s """ params = (*scope_params, query, limit_value) else: sql = f""" SELECT * FROM {self._memory_table} WHERE {where_scope} AND content_text LIKE %s ORDER BY timestamp DESC LIMIT %s """ params = (*scope_params, f"%{query}%", limit_value) async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, params) rows = await cursor.fetchall() columns = [col[0] for col in cursor.description or []] records: list[StoredMemory] = [] for row in rows: rec = cast("StoredMemory", dict(zip(columns, row, strict=False))) rec["content_json"] = _json_dict(rec.get("content_json")) metadata_val = rec.get("metadata_json") rec["metadata_json"] = _json_dict(metadata_val) if metadata_val is not None else None rec["embedding"] = None records.append(rec) return records async def delete_entries_by_session(self, session_id: str) -> int: """Delete all memory entries for a specific session.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) sql = f"DELETE FROM {self._memory_table} WHERE session_id = %s" async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, (session_id,)) await conn.commit() return resolve_rowcount(cursor) async def delete_entries_older_than( self, days: int, app_name: "str | None" = None, scope: "str | None" = None ) -> int: """Delete memory entries older than specified days.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) clauses = ["inserted_at < (UTC_TIMESTAMP(6) - INTERVAL %s DAY)"] params: list[Any] = [days] if app_name is not None: clauses.append("app_name = %s") params.append(app_name) if scope is not None: clauses.append("scope = %s") params.append(scope) where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" async with self._config.provide_connection() as conn, conn.cursor() as cursor: await cursor.execute(sql, tuple(params)) await conn.commit() return resolve_rowcount(cursor) async def _memory_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for memory entries.""" adk_config = _adk_config(self._config) owner_id_line = "" fk_constraint = "" if self._owner_id_column_ddl: col_def, fk_def = _mysql_owner_id_column_parts(self._owner_id_column_ddl) owner_id_line = f",\n {col_def}" if fk_def: fk_constraint = f",\n {fk_def}" fts_index = "" if self._use_fts: fts_index = f",\n FULLTEXT INDEX idx_{self._memory_table}_fts (content_text)" table_options = _mysql_table_options(adk_config, "memory_table_options") return f""" CREATE TABLE IF NOT EXISTS {self._memory_table} ( id VARCHAR(128) PRIMARY KEY, session_id VARCHAR(128) NOT NULL, app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, scope VARCHAR(16) NOT NULL DEFAULT 'user', event_id VARCHAR(128) NOT NULL UNIQUE, author VARCHAR(256){owner_id_line}, timestamp TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), content_json JSON NOT NULL, content_text TEXT NOT NULL, metadata_json JSON, inserted_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), INDEX idx_{self._memory_table}_app_scope_user_time (app_name, scope, user_id, timestamp), INDEX idx_{self._memory_table}_scope (app_name, scope), INDEX idx_{self._memory_table}_session (session_id){fts_index}{fk_constraint} ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci{table_options} """ def _drop_memory_table_sql(self) -> "list[str]": """Get MySQL DROP TABLE SQL statements.""" return [f"DROP TABLE IF EXISTS {self._memory_table}"] def _mysql_owner_id_column_parts(column_ddl: str) -> "tuple[str, str]": """Parse owner ID column DDL for MySQL FOREIGN KEY syntax. Args: column_ddl: Column DDL like "tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE". Returns: Tuple of (column_definition, foreign_key_constraint). """ references_match = re.search(r"\s+REFERENCES\s+(.+)", column_ddl, re.IGNORECASE) if not references_match: return (column_ddl.strip(), "") col_def = column_ddl[: references_match.start()].strip() fk_clause = references_match.group(1).strip() col_name = col_def.split()[0] fk_constraint = f"FOREIGN KEY ({col_name}) REFERENCES {fk_clause}" return (col_def, fk_constraint) def _adk_config(config: Any) -> AsyncmyADKConfig: """Return asyncmy ADK extension settings from ``extension_config["adk"]``.""" extension_config = getattr(config, "extension_config", {}) if not isinstance(extension_config, dict): return {} adk_config = extension_config.get("adk", {}) if not isinstance(adk_config, dict): return {} return cast("AsyncmyADKConfig", adk_config) def _mysql_table_options(adk_config: Mapping[str, Any], key: str) -> str: value = adk_config.get(key) if not isinstance(value, str): return "" value = value.strip() return f" {value}" if value else "" def _is_mysql_table_missing(exc: BaseException) -> bool: args = getattr(exc, "args", ()) errno = getattr(exc, "errno", None) return ( errno == MYSQL_TABLE_NOT_FOUND_ERROR or "doesn't exist" in str(exc) or bool(args and args[0] == MYSQL_TABLE_NOT_FOUND_ERROR) ) def _json_for_storage(value: Any) -> str: return value if isinstance(value, str) else to_json(value) def _json_dict(value: Any) -> "dict[str, Any]": if isinstance(value, bytearray): value = bytes(value) if isinstance(value, (bytes, str)): return cast("dict[str, Any]", from_json(value)) return cast("dict[str, Any]", value) def _session_record_from_row(row: Any) -> StoredSession: return StoredSession( id=row[0], app_name=row[1], user_id=row[2], state=_json_dict(row[3]), create_time=row[4], update_time=row[5] ) def _event_record_from_row(row: Any) -> StoredEvent: return StoredEvent( id=row[0], app_name=row[1], user_id=row[2], session_id=row[3], invocation_id=row[4], timestamp=row[5], event_data=_json_dict(row[6]), ) def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": return ( event_record["id"], event_record["app_name"], event_record["user_id"], event_record["session_id"], event_record["invocation_id"], event_record["timestamp"], _json_for_storage(event_record["event_data"]), ) def _mysql_sessions_ddl(session_table: str, owner_id_column_ddl: "str | None", table_options: str = "") -> str: owner_id_line = "" fk_constraint = "" if owner_id_column_ddl: col_def, fk_def = _mysql_owner_id_column_parts(owner_id_column_ddl) owner_id_line = f"\n {col_def}," if fk_def: fk_constraint = f",\n {fk_def}" return f""" CREATE TABLE IF NOT EXISTS {session_table} ( id VARCHAR(128) PRIMARY KEY, app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL,{owner_id_line} state JSON NOT NULL, create_time TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), update_time TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX idx_{session_table}_app_user (app_name, user_id), INDEX idx_{session_table}_update_time (update_time DESC){fk_constraint} ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci{table_options} """ def _mysql_events_ddl(events_table: str, session_table: str, adk_config: Mapping[str, Any] | None = None) -> str: adk_config = adk_config or {} generated_columns = "" generated_indexes = "" if adk_config.get("enable_event_generated_columns", False): generated_columns = """, author_gc VARCHAR(256) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.author'))) STORED, node_path_gc VARCHAR(512) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.node_info.path'))) STORED""" generated_indexes = f""", INDEX idx_{events_table}_author_gc (session_id, author_gc, timestamp ASC), INDEX idx_{events_table}_node_path_gc (session_id, node_path_gc, timestamp ASC)""" covering_column = ", invocation_id" if adk_config.get("enable_covering_indexes", False) else "" table_options = _mysql_table_options(adk_config, "events_table_options") return f""" CREATE TABLE IF NOT EXISTS {events_table} ( id VARCHAR(128) PRIMARY KEY, app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, session_id VARCHAR(128) NOT NULL, invocation_id VARCHAR(256) NOT NULL, timestamp TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), event_data JSON NOT NULL{generated_columns}, FOREIGN KEY (session_id) REFERENCES {session_table}(id) ON DELETE CASCADE, INDEX idx_{events_table}_scope (app_name, user_id, session_id, timestamp ASC{covering_column}), INDEX idx_{events_table}_session (session_id, timestamp ASC{covering_column}), INDEX idx_{events_table}_app_timestamp (app_name, timestamp ASC){generated_indexes} ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci{table_options} """ def _mysql_app_state_ddl(app_state_table: str, table_options: str = "") -> str: return f""" CREATE TABLE IF NOT EXISTS {app_state_table} ( app_name VARCHAR(128) PRIMARY KEY, state JSON NOT NULL, update_time TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci{table_options} """ def _mysql_user_state_ddl(user_state_table: str, table_options: str = "") -> str: return f""" CREATE TABLE IF NOT EXISTS {user_state_table} ( app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, state JSON NOT NULL, update_time TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (app_name, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci{table_options} """ def _mysql_metadata_ddl(metadata_table: str) -> str: return f""" CREATE TABLE IF NOT EXISTS {metadata_table} ( `key` VARCHAR(128) PRIMARY KEY, value VARCHAR(512) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """ def _mysql_upsert_app_state_sql(app_state_table: str) -> str: return f""" INSERT INTO {app_state_table} (app_name, state, update_time) VALUES (%s, %s, UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE state = VALUES(state), update_time = UTC_TIMESTAMP(6) """ def _mysql_upsert_user_state_sql(user_state_table: str) -> str: return f""" INSERT INTO {user_state_table} (app_name, user_id, state, update_time) VALUES (%s, %s, %s, UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE state = VALUES(state), update_time = UTC_TIMESTAMP(6) """ def _mysql_upsert_metadata_sql(metadata_table: str) -> str: return f""" INSERT INTO {metadata_table} (`key`, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value) """ def _raise_session_not_found(session_id: str) -> None: msg = f"Session {session_id} not found during append_event_and_update_state." raise ValueError(msg) def _build_mysql_scope_where( app_name: str, user_id: str, scope_filter: Literal["all", "user", "app"] ) -> tuple[str, tuple[Any, ...]]: if scope_filter == "all": return "app_name = %s AND ((scope = 'user' AND user_id = %s) OR scope = 'app')", (app_name, user_id) if scope_filter == "user": return "app_name = %s AND scope = 'user' AND user_id = %s", (app_name, user_id) return "app_name = %s AND scope = 'app'", (app_name,) def _session_list_query( session_table: str, app_name: str, user_id: "str | None", column: str, direction: str, limit: "int | None", offset: int, ) -> "tuple[str, tuple[Any, ...]]": """Return the bounded session-list query and its bound values.""" params: list[Any] = [app_name] where_clause = "app_name = %s" if user_id is not None: params.append(user_id) where_clause = f"{where_clause} AND user_id = %s" page_clause = "" if limit is not None: params.extend((limit, offset)) page_clause = "\n LIMIT %s OFFSET %s" sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {session_table} WHERE {where_clause} ORDER BY {column} {direction}, id {direction}{page_clause} """ return sql, tuple(params)