sqlspec 0.26.0__py3-none-any.whl → 0.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sqlspec might be problematic. Click here for more details.

Files changed (212) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +55 -25
  3. sqlspec/_typing.py +155 -52
  4. sqlspec/adapters/adbc/_types.py +1 -1
  5. sqlspec/adapters/adbc/adk/__init__.py +5 -0
  6. sqlspec/adapters/adbc/adk/store.py +880 -0
  7. sqlspec/adapters/adbc/config.py +62 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +74 -2
  9. sqlspec/adapters/adbc/driver.py +226 -58
  10. sqlspec/adapters/adbc/litestar/__init__.py +5 -0
  11. sqlspec/adapters/adbc/litestar/store.py +504 -0
  12. sqlspec/adapters/adbc/type_converter.py +44 -50
  13. sqlspec/adapters/aiosqlite/_types.py +1 -1
  14. sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
  15. sqlspec/adapters/aiosqlite/adk/store.py +536 -0
  16. sqlspec/adapters/aiosqlite/config.py +86 -16
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +34 -2
  18. sqlspec/adapters/aiosqlite/driver.py +127 -38
  19. sqlspec/adapters/aiosqlite/litestar/__init__.py +5 -0
  20. sqlspec/adapters/aiosqlite/litestar/store.py +281 -0
  21. sqlspec/adapters/aiosqlite/pool.py +7 -7
  22. sqlspec/adapters/asyncmy/__init__.py +7 -1
  23. sqlspec/adapters/asyncmy/_types.py +1 -1
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +503 -0
  26. sqlspec/adapters/asyncmy/config.py +59 -17
  27. sqlspec/adapters/asyncmy/data_dictionary.py +41 -2
  28. sqlspec/adapters/asyncmy/driver.py +293 -62
  29. sqlspec/adapters/asyncmy/litestar/__init__.py +5 -0
  30. sqlspec/adapters/asyncmy/litestar/store.py +296 -0
  31. sqlspec/adapters/asyncpg/__init__.py +2 -1
  32. sqlspec/adapters/asyncpg/_type_handlers.py +71 -0
  33. sqlspec/adapters/asyncpg/_types.py +11 -7
  34. sqlspec/adapters/asyncpg/adk/__init__.py +5 -0
  35. sqlspec/adapters/asyncpg/adk/store.py +460 -0
  36. sqlspec/adapters/asyncpg/config.py +57 -36
  37. sqlspec/adapters/asyncpg/data_dictionary.py +48 -2
  38. sqlspec/adapters/asyncpg/driver.py +153 -23
  39. sqlspec/adapters/asyncpg/litestar/__init__.py +5 -0
  40. sqlspec/adapters/asyncpg/litestar/store.py +253 -0
  41. sqlspec/adapters/bigquery/_types.py +1 -1
  42. sqlspec/adapters/bigquery/adk/__init__.py +5 -0
  43. sqlspec/adapters/bigquery/adk/store.py +585 -0
  44. sqlspec/adapters/bigquery/config.py +36 -11
  45. sqlspec/adapters/bigquery/data_dictionary.py +42 -2
  46. sqlspec/adapters/bigquery/driver.py +489 -144
  47. sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
  48. sqlspec/adapters/bigquery/litestar/store.py +327 -0
  49. sqlspec/adapters/bigquery/type_converter.py +55 -23
  50. sqlspec/adapters/duckdb/_types.py +2 -2
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +563 -0
  53. sqlspec/adapters/duckdb/config.py +79 -21
  54. sqlspec/adapters/duckdb/data_dictionary.py +41 -2
  55. sqlspec/adapters/duckdb/driver.py +225 -44
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +5 -5
  59. sqlspec/adapters/duckdb/type_converter.py +51 -21
  60. sqlspec/adapters/oracledb/_numpy_handlers.py +133 -0
  61. sqlspec/adapters/oracledb/_types.py +20 -2
  62. sqlspec/adapters/oracledb/adk/__init__.py +5 -0
  63. sqlspec/adapters/oracledb/adk/store.py +1628 -0
  64. sqlspec/adapters/oracledb/config.py +120 -36
  65. sqlspec/adapters/oracledb/data_dictionary.py +87 -20
  66. sqlspec/adapters/oracledb/driver.py +475 -86
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +765 -0
  69. sqlspec/adapters/oracledb/migrations.py +316 -25
  70. sqlspec/adapters/oracledb/type_converter.py +91 -16
  71. sqlspec/adapters/psqlpy/_type_handlers.py +44 -0
  72. sqlspec/adapters/psqlpy/_types.py +2 -1
  73. sqlspec/adapters/psqlpy/adk/__init__.py +5 -0
  74. sqlspec/adapters/psqlpy/adk/store.py +483 -0
  75. sqlspec/adapters/psqlpy/config.py +45 -19
  76. sqlspec/adapters/psqlpy/data_dictionary.py +48 -2
  77. sqlspec/adapters/psqlpy/driver.py +108 -41
  78. sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
  79. sqlspec/adapters/psqlpy/litestar/store.py +272 -0
  80. sqlspec/adapters/psqlpy/type_converter.py +40 -11
  81. sqlspec/adapters/psycopg/_type_handlers.py +80 -0
  82. sqlspec/adapters/psycopg/_types.py +2 -1
  83. sqlspec/adapters/psycopg/adk/__init__.py +5 -0
  84. sqlspec/adapters/psycopg/adk/store.py +962 -0
  85. sqlspec/adapters/psycopg/config.py +65 -37
  86. sqlspec/adapters/psycopg/data_dictionary.py +91 -3
  87. sqlspec/adapters/psycopg/driver.py +200 -78
  88. sqlspec/adapters/psycopg/litestar/__init__.py +5 -0
  89. sqlspec/adapters/psycopg/litestar/store.py +554 -0
  90. sqlspec/adapters/sqlite/__init__.py +2 -1
  91. sqlspec/adapters/sqlite/_type_handlers.py +86 -0
  92. sqlspec/adapters/sqlite/_types.py +1 -1
  93. sqlspec/adapters/sqlite/adk/__init__.py +5 -0
  94. sqlspec/adapters/sqlite/adk/store.py +582 -0
  95. sqlspec/adapters/sqlite/config.py +85 -16
  96. sqlspec/adapters/sqlite/data_dictionary.py +34 -2
  97. sqlspec/adapters/sqlite/driver.py +120 -52
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +5 -5
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +91 -58
  104. sqlspec/builder/_column.py +5 -5
  105. sqlspec/builder/_ddl.py +98 -89
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +41 -44
  109. sqlspec/builder/_insert.py +5 -82
  110. sqlspec/builder/{mixins/_join_operations.py → _join.py} +145 -143
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +9 -11
  113. sqlspec/builder/_select.py +1313 -25
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +76 -69
  116. sqlspec/config.py +331 -62
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +18 -18
  119. sqlspec/core/compiler.py +6 -8
  120. sqlspec/core/filters.py +55 -47
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +76 -45
  123. sqlspec/core/result.py +234 -47
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +32 -31
  126. sqlspec/core/type_conversion.py +3 -2
  127. sqlspec/driver/__init__.py +1 -3
  128. sqlspec/driver/_async.py +183 -160
  129. sqlspec/driver/_common.py +197 -109
  130. sqlspec/driver/_sync.py +189 -161
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +70 -7
  134. sqlspec/extensions/adk/__init__.py +53 -0
  135. sqlspec/extensions/adk/_types.py +51 -0
  136. sqlspec/extensions/adk/converters.py +172 -0
  137. sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +144 -0
  138. sqlspec/extensions/adk/migrations/__init__.py +0 -0
  139. sqlspec/extensions/adk/service.py +181 -0
  140. sqlspec/extensions/adk/store.py +536 -0
  141. sqlspec/extensions/aiosql/adapter.py +69 -61
  142. sqlspec/extensions/fastapi/__init__.py +21 -0
  143. sqlspec/extensions/fastapi/extension.py +331 -0
  144. sqlspec/extensions/fastapi/providers.py +543 -0
  145. sqlspec/extensions/flask/__init__.py +36 -0
  146. sqlspec/extensions/flask/_state.py +71 -0
  147. sqlspec/extensions/flask/_utils.py +40 -0
  148. sqlspec/extensions/flask/extension.py +389 -0
  149. sqlspec/extensions/litestar/__init__.py +21 -4
  150. sqlspec/extensions/litestar/cli.py +54 -10
  151. sqlspec/extensions/litestar/config.py +56 -266
  152. sqlspec/extensions/litestar/handlers.py +46 -17
  153. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  154. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  155. sqlspec/extensions/litestar/plugin.py +349 -224
  156. sqlspec/extensions/litestar/providers.py +25 -25
  157. sqlspec/extensions/litestar/store.py +265 -0
  158. sqlspec/extensions/starlette/__init__.py +10 -0
  159. sqlspec/extensions/starlette/_state.py +25 -0
  160. sqlspec/extensions/starlette/_utils.py +52 -0
  161. sqlspec/extensions/starlette/extension.py +254 -0
  162. sqlspec/extensions/starlette/middleware.py +154 -0
  163. sqlspec/loader.py +30 -49
  164. sqlspec/migrations/base.py +200 -76
  165. sqlspec/migrations/commands.py +591 -62
  166. sqlspec/migrations/context.py +6 -9
  167. sqlspec/migrations/fix.py +199 -0
  168. sqlspec/migrations/loaders.py +47 -19
  169. sqlspec/migrations/runner.py +241 -75
  170. sqlspec/migrations/tracker.py +237 -21
  171. sqlspec/migrations/utils.py +51 -3
  172. sqlspec/migrations/validation.py +177 -0
  173. sqlspec/protocols.py +106 -36
  174. sqlspec/storage/_utils.py +85 -0
  175. sqlspec/storage/backends/fsspec.py +133 -107
  176. sqlspec/storage/backends/local.py +78 -51
  177. sqlspec/storage/backends/obstore.py +276 -168
  178. sqlspec/storage/registry.py +75 -39
  179. sqlspec/typing.py +30 -84
  180. sqlspec/utils/__init__.py +25 -4
  181. sqlspec/utils/arrow_helpers.py +81 -0
  182. sqlspec/utils/config_resolver.py +6 -6
  183. sqlspec/utils/correlation.py +4 -5
  184. sqlspec/utils/data_transformation.py +3 -2
  185. sqlspec/utils/deprecation.py +9 -8
  186. sqlspec/utils/fixtures.py +4 -4
  187. sqlspec/utils/logging.py +46 -6
  188. sqlspec/utils/module_loader.py +205 -5
  189. sqlspec/utils/portal.py +311 -0
  190. sqlspec/utils/schema.py +288 -0
  191. sqlspec/utils/serializers.py +113 -4
  192. sqlspec/utils/sync_tools.py +36 -22
  193. sqlspec/utils/text.py +1 -2
  194. sqlspec/utils/type_guards.py +136 -20
  195. sqlspec/utils/version.py +433 -0
  196. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/METADATA +41 -22
  197. sqlspec-0.28.0.dist-info/RECORD +221 -0
  198. sqlspec/builder/mixins/__init__.py +0 -55
  199. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -253
  200. sqlspec/builder/mixins/_delete_operations.py +0 -50
  201. sqlspec/builder/mixins/_insert_operations.py +0 -282
  202. sqlspec/builder/mixins/_merge_operations.py +0 -698
  203. sqlspec/builder/mixins/_order_limit_operations.py +0 -145
  204. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  205. sqlspec/builder/mixins/_select_operations.py +0 -930
  206. sqlspec/builder/mixins/_update_operations.py +0 -199
  207. sqlspec/builder/mixins/_where_clause.py +0 -1298
  208. sqlspec-0.26.0.dist-info/RECORD +0 -157
  209. sqlspec-0.26.0.dist-info/licenses/NOTICE +0 -29
  210. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/WHEEL +0 -0
  211. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/entry_points.txt +0 -0
  212. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,962 @@
1
+ """Psycopg ADK store for Google Agent Development Kit session/event storage."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from psycopg import errors
6
+ from psycopg import sql as pg_sql
7
+ from psycopg.types.json import Jsonb
8
+
9
+ from sqlspec.extensions.adk import BaseAsyncADKStore, BaseSyncADKStore, EventRecord, SessionRecord
10
+ from sqlspec.utils.logging import get_logger
11
+
12
+ if TYPE_CHECKING:
13
+ from datetime import datetime
14
+
15
+ from sqlspec.adapters.psycopg.config import PsycopgAsyncConfig, PsycopgSyncConfig
16
+
17
+ logger = get_logger("adapters.psycopg.adk.store")
18
+
19
+ __all__ = ("PsycopgAsyncADKStore", "PsycopgSyncADKStore")
20
+
21
+
22
+ class PsycopgAsyncADKStore(BaseAsyncADKStore["PsycopgAsyncConfig"]):
23
+ """PostgreSQL ADK store using Psycopg3 driver.
24
+
25
+ Implements session and event storage for Google Agent Development Kit
26
+ using PostgreSQL via psycopg3 with native async/await support.
27
+
28
+ Provides:
29
+ - Session state management with JSONB storage and merge operations
30
+ - Event history tracking with BYTEA-serialized actions
31
+ - Microsecond-precision timestamps with TIMESTAMPTZ
32
+ - Foreign key constraints with cascade delete
33
+ - Efficient upserts using ON CONFLICT
34
+ - GIN indexes for JSONB queries
35
+ - HOT updates with FILLFACTOR 80
36
+
37
+ Args:
38
+ config: PsycopgAsyncConfig with extension_config["adk"] settings.
39
+
40
+ Example:
41
+ from sqlspec.adapters.psycopg import PsycopgAsyncConfig
42
+ from sqlspec.adapters.psycopg.adk import PsycopgAsyncADKStore
43
+
44
+ config = PsycopgAsyncConfig(
45
+ pool_config={"conninfo": "postgresql://..."},
46
+ extension_config={
47
+ "adk": {
48
+ "session_table": "my_sessions",
49
+ "events_table": "my_events",
50
+ "owner_id_column": "tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE"
51
+ }
52
+ }
53
+ )
54
+ store = PsycopgAsyncADKStore(config)
55
+ await store.create_tables()
56
+
57
+ Notes:
58
+ - PostgreSQL JSONB type used for state (more efficient than JSON)
59
+ - Psycopg requires wrapping dicts with Jsonb() for type safety
60
+ - TIMESTAMPTZ provides timezone-aware microsecond precision
61
+ - State merging uses `state || $1::jsonb` operator for efficiency
62
+ - BYTEA for pre-serialized actions from Google ADK
63
+ - GIN index on state for JSONB queries (partial index)
64
+ - FILLFACTOR 80 leaves space for HOT updates
65
+ - Parameter style: $1, $2, $3 (PostgreSQL numeric placeholders)
66
+ - Configuration is read from config.extension_config["adk"]
67
+ """
68
+
69
+ __slots__ = ()
70
+
71
+ def __init__(self, config: "PsycopgAsyncConfig") -> None:
72
+ """Initialize Psycopg ADK store.
73
+
74
+ Args:
75
+ config: PsycopgAsyncConfig instance.
76
+
77
+ Notes:
78
+ Configuration is read from config.extension_config["adk"]:
79
+ - session_table: Sessions table name (default: "adk_sessions")
80
+ - events_table: Events table name (default: "adk_events")
81
+ - owner_id_column: Optional owner FK column DDL (default: None)
82
+ """
83
+ super().__init__(config)
84
+
85
+ async def _get_create_sessions_table_sql(self) -> str:
86
+ """Get PostgreSQL CREATE TABLE SQL for sessions.
87
+
88
+ Returns:
89
+ SQL statement to create adk_sessions table with indexes.
90
+
91
+ Notes:
92
+ - VARCHAR(128) for IDs and names (sufficient for UUIDs and app names)
93
+ - JSONB type for state storage with default empty object
94
+ - TIMESTAMPTZ with microsecond precision
95
+ - FILLFACTOR 80 for HOT updates (reduces table bloat)
96
+ - Composite index on (app_name, user_id) for listing
97
+ - Index on update_time DESC for recent session queries
98
+ - Partial GIN index on state for JSONB queries (only non-empty)
99
+ - Optional owner ID column for multi-tenancy or user references
100
+ """
101
+ owner_id_line = ""
102
+ if self._owner_id_column_ddl:
103
+ owner_id_line = f",\n {self._owner_id_column_ddl}"
104
+
105
+ return f"""
106
+ CREATE TABLE IF NOT EXISTS {self._session_table} (
107
+ id VARCHAR(128) PRIMARY KEY,
108
+ app_name VARCHAR(128) NOT NULL,
109
+ user_id VARCHAR(128) NOT NULL{owner_id_line},
110
+ state JSONB NOT NULL DEFAULT '{{}}'::jsonb,
111
+ create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
112
+ update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
113
+ ) WITH (fillfactor = 80);
114
+
115
+ CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user
116
+ ON {self._session_table}(app_name, user_id);
117
+
118
+ CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time
119
+ ON {self._session_table}(update_time DESC);
120
+
121
+ CREATE INDEX IF NOT EXISTS idx_{self._session_table}_state
122
+ ON {self._session_table} USING GIN (state)
123
+ WHERE state != '{{}}'::jsonb;
124
+ """
125
+
126
+ async def _get_create_events_table_sql(self) -> str:
127
+ """Get PostgreSQL CREATE TABLE SQL for events.
128
+
129
+ Returns:
130
+ SQL statement to create adk_events table with indexes.
131
+
132
+ Notes:
133
+ - VARCHAR sizes: id(128), session_id(128), invocation_id(256), author(256),
134
+ branch(256), error_code(256), error_message(1024)
135
+ - BYTEA for pickled actions (no size limit)
136
+ - JSONB for content, grounding_metadata, custom_metadata, long_running_tool_ids_json
137
+ - BOOLEAN for partial, turn_complete, interrupted
138
+ - Foreign key to sessions with CASCADE delete
139
+ - Index on (session_id, timestamp ASC) for ordered event retrieval
140
+ """
141
+ return f"""
142
+ CREATE TABLE IF NOT EXISTS {self._events_table} (
143
+ id VARCHAR(128) PRIMARY KEY,
144
+ session_id VARCHAR(128) NOT NULL,
145
+ app_name VARCHAR(128) NOT NULL,
146
+ user_id VARCHAR(128) NOT NULL,
147
+ invocation_id VARCHAR(256),
148
+ author VARCHAR(256),
149
+ actions BYTEA,
150
+ long_running_tool_ids_json JSONB,
151
+ branch VARCHAR(256),
152
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
153
+ content JSONB,
154
+ grounding_metadata JSONB,
155
+ custom_metadata JSONB,
156
+ partial BOOLEAN,
157
+ turn_complete BOOLEAN,
158
+ interrupted BOOLEAN,
159
+ error_code VARCHAR(256),
160
+ error_message VARCHAR(1024),
161
+ FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ON DELETE CASCADE
162
+ );
163
+
164
+ CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session
165
+ ON {self._events_table}(session_id, timestamp ASC);
166
+ """
167
+
168
+ def _get_drop_tables_sql(self) -> "list[str]":
169
+ """Get PostgreSQL DROP TABLE SQL statements.
170
+
171
+ Returns:
172
+ List of SQL statements to drop tables and indexes.
173
+
174
+ Notes:
175
+ Order matters: drop events table (child) before sessions (parent).
176
+ PostgreSQL automatically drops indexes when dropping tables.
177
+ """
178
+ return [f"DROP TABLE IF EXISTS {self._events_table}", f"DROP TABLE IF EXISTS {self._session_table}"]
179
+
180
+ async def create_tables(self) -> None:
181
+ """Create both sessions and events tables if they don't exist."""
182
+ async with self._config.provide_session() as driver:
183
+ await driver.execute_script(await self._get_create_sessions_table_sql())
184
+ await driver.execute_script(await self._get_create_events_table_sql())
185
+ logger.debug("Created ADK tables: %s, %s", self._session_table, self._events_table)
186
+
187
+ async def create_session(
188
+ self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None
189
+ ) -> SessionRecord:
190
+ """Create a new session.
191
+
192
+ Args:
193
+ session_id: Unique session identifier.
194
+ app_name: Application name.
195
+ user_id: User identifier.
196
+ state: Initial session state.
197
+ owner_id: Optional owner ID value for owner_id_column (if configured).
198
+
199
+ Returns:
200
+ Created session record.
201
+
202
+ Notes:
203
+ Uses CURRENT_TIMESTAMP for create_time and update_time.
204
+ State is wrapped with Jsonb() for PostgreSQL type safety.
205
+ If owner_id_column is configured, owner_id value must be provided.
206
+ """
207
+ params: tuple[Any, ...]
208
+ if self._owner_id_column_name:
209
+ query = pg_sql.SQL("""
210
+ INSERT INTO {table} (id, app_name, user_id, {owner_id_col}, state, create_time, update_time)
211
+ VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
212
+ """).format(
213
+ table=pg_sql.Identifier(self._session_table), owner_id_col=pg_sql.Identifier(self._owner_id_column_name)
214
+ )
215
+ params = (session_id, app_name, user_id, owner_id, Jsonb(state))
216
+ else:
217
+ query = pg_sql.SQL("""
218
+ INSERT INTO {table} (id, app_name, user_id, state, create_time, update_time)
219
+ VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
220
+ """).format(table=pg_sql.Identifier(self._session_table))
221
+ params = (session_id, app_name, user_id, Jsonb(state))
222
+
223
+ async with self._config.provide_connection() as conn, conn.cursor() as cur:
224
+ await cur.execute(query, params)
225
+
226
+ return await self.get_session(session_id) # type: ignore[return-value]
227
+
228
+ async def get_session(self, session_id: str) -> "SessionRecord | None":
229
+ """Get session by ID.
230
+
231
+ Args:
232
+ session_id: Session identifier.
233
+
234
+ Returns:
235
+ Session record or None if not found.
236
+
237
+ Notes:
238
+ PostgreSQL returns datetime objects for TIMESTAMPTZ columns.
239
+ JSONB is automatically deserialized by psycopg to Python dict.
240
+ """
241
+ query = pg_sql.SQL("""
242
+ SELECT id, app_name, user_id, state, create_time, update_time
243
+ FROM {table}
244
+ WHERE id = %s
245
+ """).format(table=pg_sql.Identifier(self._session_table))
246
+
247
+ try:
248
+ async with self._config.provide_connection() as conn, conn.cursor() as cur:
249
+ await cur.execute(query, (session_id,))
250
+ row = await cur.fetchone()
251
+
252
+ if row is None:
253
+ return None
254
+
255
+ return SessionRecord(
256
+ id=row["id"],
257
+ app_name=row["app_name"],
258
+ user_id=row["user_id"],
259
+ state=row["state"],
260
+ create_time=row["create_time"],
261
+ update_time=row["update_time"],
262
+ )
263
+ except errors.UndefinedTable:
264
+ return None
265
+
266
+ async def update_session_state(self, session_id: str, state: "dict[str, Any]") -> None:
267
+ """Update session state.
268
+
269
+ Args:
270
+ session_id: Session identifier.
271
+ state: New state dictionary (replaces existing state).
272
+
273
+ Notes:
274
+ This replaces the entire state dictionary.
275
+ Uses CURRENT_TIMESTAMP for update_time.
276
+ State is wrapped with Jsonb() for PostgreSQL type safety.
277
+ """
278
+ query = pg_sql.SQL("""
279
+ UPDATE {table}
280
+ SET state = %s, update_time = CURRENT_TIMESTAMP
281
+ WHERE id = %s
282
+ """).format(table=pg_sql.Identifier(self._session_table))
283
+
284
+ async with self._config.provide_connection() as conn, conn.cursor() as cur:
285
+ await cur.execute(query, (Jsonb(state), session_id))
286
+
287
+ async def delete_session(self, session_id: str) -> None:
288
+ """Delete session and all associated events (cascade).
289
+
290
+ Args:
291
+ session_id: Session identifier.
292
+
293
+ Notes:
294
+ Foreign key constraint ensures events are cascade-deleted.
295
+ """
296
+ query = pg_sql.SQL("DELETE FROM {table} WHERE id = %s").format(table=pg_sql.Identifier(self._session_table))
297
+
298
+ async with self._config.provide_connection() as conn, conn.cursor() as cur:
299
+ await cur.execute(query, (session_id,))
300
+
301
+ async def list_sessions(self, app_name: str, user_id: str | None = None) -> "list[SessionRecord]":
302
+ """List sessions for an app, optionally filtered by user.
303
+
304
+ Args:
305
+ app_name: Application name.
306
+ user_id: User identifier. If None, lists all sessions for the app.
307
+
308
+ Returns:
309
+ List of session records ordered by update_time DESC.
310
+
311
+ Notes:
312
+ Uses composite index on (app_name, user_id) when user_id is provided.
313
+ """
314
+ if user_id is None:
315
+ query = pg_sql.SQL("""
316
+ SELECT id, app_name, user_id, state, create_time, update_time
317
+ FROM {table}
318
+ WHERE app_name = %s
319
+ ORDER BY update_time DESC
320
+ """).format(table=pg_sql.Identifier(self._session_table))
321
+ params: tuple[str, ...] = (app_name,)
322
+ else:
323
+ query = pg_sql.SQL("""
324
+ SELECT id, app_name, user_id, state, create_time, update_time
325
+ FROM {table}
326
+ WHERE app_name = %s AND user_id = %s
327
+ ORDER BY update_time DESC
328
+ """).format(table=pg_sql.Identifier(self._session_table))
329
+ params = (app_name, user_id)
330
+
331
+ try:
332
+ async with self._config.provide_connection() as conn, conn.cursor() as cur:
333
+ await cur.execute(query, params)
334
+ rows = await cur.fetchall()
335
+
336
+ return [
337
+ SessionRecord(
338
+ id=row["id"],
339
+ app_name=row["app_name"],
340
+ user_id=row["user_id"],
341
+ state=row["state"],
342
+ create_time=row["create_time"],
343
+ update_time=row["update_time"],
344
+ )
345
+ for row in rows
346
+ ]
347
+ except errors.UndefinedTable:
348
+ return []
349
+
350
+ async def append_event(self, event_record: EventRecord) -> None:
351
+ """Append an event to a session.
352
+
353
+ Args:
354
+ event_record: Event record to store.
355
+
356
+ Notes:
357
+ Uses CURRENT_TIMESTAMP for timestamp if not provided.
358
+ JSONB fields are wrapped with Jsonb() for PostgreSQL type safety.
359
+ """
360
+ content_json = event_record.get("content")
361
+ grounding_metadata_json = event_record.get("grounding_metadata")
362
+ custom_metadata_json = event_record.get("custom_metadata")
363
+
364
+ query = pg_sql.SQL("""
365
+ INSERT INTO {table} (
366
+ id, session_id, app_name, user_id, invocation_id, author, actions,
367
+ long_running_tool_ids_json, branch, timestamp, content,
368
+ grounding_metadata, custom_metadata, partial, turn_complete,
369
+ interrupted, error_code, error_message
370
+ ) VALUES (
371
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
372
+ )
373
+ """).format(table=pg_sql.Identifier(self._events_table))
374
+
375
+ async with self._config.provide_connection() as conn, conn.cursor() as cur:
376
+ await cur.execute(
377
+ query,
378
+ (
379
+ event_record["id"],
380
+ event_record["session_id"],
381
+ event_record["app_name"],
382
+ event_record["user_id"],
383
+ event_record.get("invocation_id"),
384
+ event_record.get("author"),
385
+ event_record.get("actions"),
386
+ event_record.get("long_running_tool_ids_json"),
387
+ event_record.get("branch"),
388
+ event_record["timestamp"],
389
+ Jsonb(content_json) if content_json is not None else None,
390
+ Jsonb(grounding_metadata_json) if grounding_metadata_json is not None else None,
391
+ Jsonb(custom_metadata_json) if custom_metadata_json is not None else None,
392
+ event_record.get("partial"),
393
+ event_record.get("turn_complete"),
394
+ event_record.get("interrupted"),
395
+ event_record.get("error_code"),
396
+ event_record.get("error_message"),
397
+ ),
398
+ )
399
+
400
+ async def get_events(
401
+ self, session_id: str, after_timestamp: "datetime | None" = None, limit: "int | None" = None
402
+ ) -> "list[EventRecord]":
403
+ """Get events for a session.
404
+
405
+ Args:
406
+ session_id: Session identifier.
407
+ after_timestamp: Only return events after this time.
408
+ limit: Maximum number of events to return.
409
+
410
+ Returns:
411
+ List of event records ordered by timestamp ASC.
412
+
413
+ Notes:
414
+ Uses index on (session_id, timestamp ASC).
415
+ JSONB fields are automatically deserialized by psycopg.
416
+ BYTEA actions are converted to bytes.
417
+ """
418
+ where_clauses = ["session_id = %s"]
419
+ params: list[Any] = [session_id]
420
+
421
+ if after_timestamp is not None:
422
+ where_clauses.append("timestamp > %s")
423
+ params.append(after_timestamp)
424
+
425
+ where_clause = " AND ".join(where_clauses)
426
+ if limit:
427
+ params.append(limit)
428
+
429
+ query = pg_sql.SQL(
430
+ """
431
+ SELECT id, session_id, app_name, user_id, invocation_id, author, actions,
432
+ long_running_tool_ids_json, branch, timestamp, content,
433
+ grounding_metadata, custom_metadata, partial, turn_complete,
434
+ interrupted, error_code, error_message
435
+ FROM {table}
436
+ WHERE {where_clause}
437
+ ORDER BY timestamp ASC{limit_clause}
438
+ """
439
+ ).format(
440
+ table=pg_sql.Identifier(self._events_table),
441
+ where_clause=pg_sql.SQL(where_clause), # pyright: ignore[reportArgumentType]
442
+ limit_clause=pg_sql.SQL(" LIMIT %s" if limit else ""), # pyright: ignore[reportArgumentType]
443
+ )
444
+
445
+ try:
446
+ async with self._config.provide_connection() as conn, conn.cursor() as cur:
447
+ await cur.execute(query, tuple(params))
448
+ rows = await cur.fetchall()
449
+
450
+ return [
451
+ EventRecord(
452
+ id=row["id"],
453
+ session_id=row["session_id"],
454
+ app_name=row["app_name"],
455
+ user_id=row["user_id"],
456
+ invocation_id=row["invocation_id"],
457
+ author=row["author"],
458
+ actions=bytes(row["actions"]) if row["actions"] else b"",
459
+ long_running_tool_ids_json=row["long_running_tool_ids_json"],
460
+ branch=row["branch"],
461
+ timestamp=row["timestamp"],
462
+ content=row["content"],
463
+ grounding_metadata=row["grounding_metadata"],
464
+ custom_metadata=row["custom_metadata"],
465
+ partial=row["partial"],
466
+ turn_complete=row["turn_complete"],
467
+ interrupted=row["interrupted"],
468
+ error_code=row["error_code"],
469
+ error_message=row["error_message"],
470
+ )
471
+ for row in rows
472
+ ]
473
+ except errors.UndefinedTable:
474
+ return []
475
+
476
+
477
+ class PsycopgSyncADKStore(BaseSyncADKStore["PsycopgSyncConfig"]):
478
+ """PostgreSQL synchronous ADK store using Psycopg3 driver.
479
+
480
+ Implements session and event storage for Google Agent Development Kit
481
+ using PostgreSQL via psycopg3 with synchronous execution.
482
+
483
+ Provides:
484
+ - Session state management with JSONB storage and merge operations
485
+ - Event history tracking with BYTEA-serialized actions
486
+ - Microsecond-precision timestamps with TIMESTAMPTZ
487
+ - Foreign key constraints with cascade delete
488
+ - Efficient upserts using ON CONFLICT
489
+ - GIN indexes for JSONB queries
490
+ - HOT updates with FILLFACTOR 80
491
+
492
+ Args:
493
+ config: PsycopgSyncConfig with extension_config["adk"] settings.
494
+
495
+ Example:
496
+ from sqlspec.adapters.psycopg import PsycopgSyncConfig
497
+ from sqlspec.adapters.psycopg.adk import PsycopgSyncADKStore
498
+
499
+ config = PsycopgSyncConfig(
500
+ pool_config={"conninfo": "postgresql://..."},
501
+ extension_config={
502
+ "adk": {
503
+ "session_table": "my_sessions",
504
+ "events_table": "my_events",
505
+ "owner_id_column": "tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE"
506
+ }
507
+ }
508
+ )
509
+ store = PsycopgSyncADKStore(config)
510
+ store.create_tables()
511
+
512
+ Notes:
513
+ - PostgreSQL JSONB type used for state (more efficient than JSON)
514
+ - Psycopg requires wrapping dicts with Jsonb() for type safety
515
+ - TIMESTAMPTZ provides timezone-aware microsecond precision
516
+ - State merging uses `state || $1::jsonb` operator for efficiency
517
+ - BYTEA for pre-serialized actions from Google ADK
518
+ - GIN index on state for JSONB queries (partial index)
519
+ - FILLFACTOR 80 leaves space for HOT updates
520
+ - Parameter style: $1, $2, $3 (PostgreSQL numeric placeholders)
521
+ - Configuration is read from config.extension_config["adk"]
522
+ """
523
+
524
+ __slots__ = ()
525
+
526
+ def __init__(self, config: "PsycopgSyncConfig") -> None:
527
+ """Initialize Psycopg synchronous ADK store.
528
+
529
+ Args:
530
+ config: PsycopgSyncConfig instance.
531
+
532
+ Notes:
533
+ Configuration is read from config.extension_config["adk"]:
534
+ - session_table: Sessions table name (default: "adk_sessions")
535
+ - events_table: Events table name (default: "adk_events")
536
+ - owner_id_column: Optional owner FK column DDL (default: None)
537
+ """
538
+ super().__init__(config)
539
+
540
+ def _get_create_sessions_table_sql(self) -> str:
541
+ """Get PostgreSQL CREATE TABLE SQL for sessions.
542
+
543
+ Returns:
544
+ SQL statement to create adk_sessions table with indexes.
545
+
546
+ Notes:
547
+ - VARCHAR(128) for IDs and names (sufficient for UUIDs and app names)
548
+ - JSONB type for state storage with default empty object
549
+ - TIMESTAMPTZ with microsecond precision
550
+ - FILLFACTOR 80 for HOT updates (reduces table bloat)
551
+ - Composite index on (app_name, user_id) for listing
552
+ - Index on update_time DESC for recent session queries
553
+ - Partial GIN index on state for JSONB queries (only non-empty)
554
+ - Optional owner ID column for multi-tenancy or user references
555
+ """
556
+ owner_id_line = ""
557
+ if self._owner_id_column_ddl:
558
+ owner_id_line = f",\n {self._owner_id_column_ddl}"
559
+
560
+ return f"""
561
+ CREATE TABLE IF NOT EXISTS {self._session_table} (
562
+ id VARCHAR(128) PRIMARY KEY,
563
+ app_name VARCHAR(128) NOT NULL,
564
+ user_id VARCHAR(128) NOT NULL{owner_id_line},
565
+ state JSONB NOT NULL DEFAULT '{{}}'::jsonb,
566
+ create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
567
+ update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
568
+ ) WITH (fillfactor = 80);
569
+
570
+ CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user
571
+ ON {self._session_table}(app_name, user_id);
572
+
573
+ CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time
574
+ ON {self._session_table}(update_time DESC);
575
+
576
+ CREATE INDEX IF NOT EXISTS idx_{self._session_table}_state
577
+ ON {self._session_table} USING GIN (state)
578
+ WHERE state != '{{}}'::jsonb;
579
+ """
580
+
581
+ def _get_create_events_table_sql(self) -> str:
582
+ """Get PostgreSQL CREATE TABLE SQL for events.
583
+
584
+ Returns:
585
+ SQL statement to create adk_events table with indexes.
586
+
587
+ Notes:
588
+ - VARCHAR sizes: id(128), session_id(128), invocation_id(256), author(256),
589
+ branch(256), error_code(256), error_message(1024)
590
+ - BYTEA for pickled actions (no size limit)
591
+ - JSONB for content, grounding_metadata, custom_metadata, long_running_tool_ids_json
592
+ - BOOLEAN for partial, turn_complete, interrupted
593
+ - Foreign key to sessions with CASCADE delete
594
+ - Index on (session_id, timestamp ASC) for ordered event retrieval
595
+ """
596
+ return f"""
597
+ CREATE TABLE IF NOT EXISTS {self._events_table} (
598
+ id VARCHAR(128) PRIMARY KEY,
599
+ session_id VARCHAR(128) NOT NULL,
600
+ app_name VARCHAR(128) NOT NULL,
601
+ user_id VARCHAR(128) NOT NULL,
602
+ invocation_id VARCHAR(256),
603
+ author VARCHAR(256),
604
+ actions BYTEA,
605
+ long_running_tool_ids_json JSONB,
606
+ branch VARCHAR(256),
607
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
608
+ content JSONB,
609
+ grounding_metadata JSONB,
610
+ custom_metadata JSONB,
611
+ partial BOOLEAN,
612
+ turn_complete BOOLEAN,
613
+ interrupted BOOLEAN,
614
+ error_code VARCHAR(256),
615
+ error_message VARCHAR(1024),
616
+ FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ON DELETE CASCADE
617
+ );
618
+
619
+ CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session
620
+ ON {self._events_table}(session_id, timestamp ASC);
621
+ """
622
+
623
+ def _get_drop_tables_sql(self) -> "list[str]":
624
+ """Get PostgreSQL DROP TABLE SQL statements.
625
+
626
+ Returns:
627
+ List of SQL statements to drop tables and indexes.
628
+
629
+ Notes:
630
+ Order matters: drop events table (child) before sessions (parent).
631
+ PostgreSQL automatically drops indexes when dropping tables.
632
+ """
633
+ return [f"DROP TABLE IF EXISTS {self._events_table}", f"DROP TABLE IF EXISTS {self._session_table}"]
634
+
635
+ def create_tables(self) -> None:
636
+ """Create both sessions and events tables if they don't exist."""
637
+ with self._config.provide_session() as driver:
638
+ driver.execute_script(self._get_create_sessions_table_sql())
639
+ driver.execute_script(self._get_create_events_table_sql())
640
+ logger.debug("Created ADK tables: %s, %s", self._session_table, self._events_table)
641
+
642
+ def create_session(
643
+ self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None
644
+ ) -> SessionRecord:
645
+ """Create a new session.
646
+
647
+ Args:
648
+ session_id: Unique session identifier.
649
+ app_name: Application name.
650
+ user_id: User identifier.
651
+ state: Initial session state.
652
+ owner_id: Optional owner ID value for owner_id_column (if configured).
653
+
654
+ Returns:
655
+ Created session record.
656
+
657
+ Notes:
658
+ Uses CURRENT_TIMESTAMP for create_time and update_time.
659
+ State is wrapped with Jsonb() for PostgreSQL type safety.
660
+ If owner_id_column is configured, owner_id value must be provided.
661
+ """
662
+ params: tuple[Any, ...]
663
+ if self._owner_id_column_name:
664
+ query = pg_sql.SQL("""
665
+ INSERT INTO {table} (id, app_name, user_id, {owner_id_col}, state, create_time, update_time)
666
+ VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
667
+ """).format(
668
+ table=pg_sql.Identifier(self._session_table), owner_id_col=pg_sql.Identifier(self._owner_id_column_name)
669
+ )
670
+ params = (session_id, app_name, user_id, owner_id, Jsonb(state))
671
+ else:
672
+ query = pg_sql.SQL("""
673
+ INSERT INTO {table} (id, app_name, user_id, state, create_time, update_time)
674
+ VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
675
+ """).format(table=pg_sql.Identifier(self._session_table))
676
+ params = (session_id, app_name, user_id, Jsonb(state))
677
+
678
+ with self._config.provide_connection() as conn, conn.cursor() as cur:
679
+ cur.execute(query, params)
680
+
681
+ return self.get_session(session_id) # type: ignore[return-value]
682
+
683
+ def get_session(self, session_id: str) -> "SessionRecord | None":
684
+ """Get session by ID.
685
+
686
+ Args:
687
+ session_id: Session identifier.
688
+
689
+ Returns:
690
+ Session record or None if not found.
691
+
692
+ Notes:
693
+ PostgreSQL returns datetime objects for TIMESTAMPTZ columns.
694
+ JSONB is automatically deserialized by psycopg to Python dict.
695
+ """
696
+ query = pg_sql.SQL("""
697
+ SELECT id, app_name, user_id, state, create_time, update_time
698
+ FROM {table}
699
+ WHERE id = %s
700
+ """).format(table=pg_sql.Identifier(self._session_table))
701
+
702
+ try:
703
+ with self._config.provide_connection() as conn, conn.cursor() as cur:
704
+ cur.execute(query, (session_id,))
705
+ row = cur.fetchone()
706
+
707
+ if row is None:
708
+ return None
709
+
710
+ return SessionRecord(
711
+ id=row["id"],
712
+ app_name=row["app_name"],
713
+ user_id=row["user_id"],
714
+ state=row["state"],
715
+ create_time=row["create_time"],
716
+ update_time=row["update_time"],
717
+ )
718
+ except errors.UndefinedTable:
719
+ return None
720
+
721
+ def update_session_state(self, session_id: str, state: "dict[str, Any]") -> None:
722
+ """Update session state.
723
+
724
+ Args:
725
+ session_id: Session identifier.
726
+ state: New state dictionary (replaces existing state).
727
+
728
+ Notes:
729
+ This replaces the entire state dictionary.
730
+ Uses CURRENT_TIMESTAMP for update_time.
731
+ State is wrapped with Jsonb() for PostgreSQL type safety.
732
+ """
733
+ query = pg_sql.SQL("""
734
+ UPDATE {table}
735
+ SET state = %s, update_time = CURRENT_TIMESTAMP
736
+ WHERE id = %s
737
+ """).format(table=pg_sql.Identifier(self._session_table))
738
+
739
+ with self._config.provide_connection() as conn, conn.cursor() as cur:
740
+ cur.execute(query, (Jsonb(state), session_id))
741
+
742
+ def delete_session(self, session_id: str) -> None:
743
+ """Delete session and all associated events (cascade).
744
+
745
+ Args:
746
+ session_id: Session identifier.
747
+
748
+ Notes:
749
+ Foreign key constraint ensures events are cascade-deleted.
750
+ """
751
+ query = pg_sql.SQL("DELETE FROM {table} WHERE id = %s").format(table=pg_sql.Identifier(self._session_table))
752
+
753
+ with self._config.provide_connection() as conn, conn.cursor() as cur:
754
+ cur.execute(query, (session_id,))
755
+
756
+ def list_sessions(self, app_name: str, user_id: str | None = None) -> "list[SessionRecord]":
757
+ """List sessions for an app, optionally filtered by user.
758
+
759
+ Args:
760
+ app_name: Application name.
761
+ user_id: User identifier. If None, lists all sessions for the app.
762
+
763
+ Returns:
764
+ List of session records ordered by update_time DESC.
765
+
766
+ Notes:
767
+ Uses composite index on (app_name, user_id) when user_id is provided.
768
+ """
769
+ if user_id is None:
770
+ query = pg_sql.SQL("""
771
+ SELECT id, app_name, user_id, state, create_time, update_time
772
+ FROM {table}
773
+ WHERE app_name = %s
774
+ ORDER BY update_time DESC
775
+ """).format(table=pg_sql.Identifier(self._session_table))
776
+ params: tuple[str, ...] = (app_name,)
777
+ else:
778
+ query = pg_sql.SQL("""
779
+ SELECT id, app_name, user_id, state, create_time, update_time
780
+ FROM {table}
781
+ WHERE app_name = %s AND user_id = %s
782
+ ORDER BY update_time DESC
783
+ """).format(table=pg_sql.Identifier(self._session_table))
784
+ params = (app_name, user_id)
785
+
786
+ try:
787
+ with self._config.provide_connection() as conn, conn.cursor() as cur:
788
+ cur.execute(query, params)
789
+ rows = cur.fetchall()
790
+
791
+ return [
792
+ SessionRecord(
793
+ id=row["id"],
794
+ app_name=row["app_name"],
795
+ user_id=row["user_id"],
796
+ state=row["state"],
797
+ create_time=row["create_time"],
798
+ update_time=row["update_time"],
799
+ )
800
+ for row in rows
801
+ ]
802
+ except errors.UndefinedTable:
803
+ return []
804
+
805
+ def create_event(
806
+ self,
807
+ event_id: str,
808
+ session_id: str,
809
+ app_name: str,
810
+ user_id: str,
811
+ author: "str | None" = None,
812
+ actions: "bytes | None" = None,
813
+ content: "dict[str, Any] | None" = None,
814
+ **kwargs: Any,
815
+ ) -> EventRecord:
816
+ """Create a new event.
817
+
818
+ Args:
819
+ event_id: Unique event identifier.
820
+ session_id: Session identifier.
821
+ app_name: Application name.
822
+ user_id: User identifier.
823
+ author: Event author (user/assistant/system).
824
+ actions: Pickled actions object.
825
+ content: Event content (JSONB).
826
+ **kwargs: Additional optional fields (invocation_id, branch, timestamp,
827
+ grounding_metadata, custom_metadata, partial, turn_complete,
828
+ interrupted, error_code, error_message, long_running_tool_ids_json).
829
+
830
+ Returns:
831
+ Created event record.
832
+
833
+ Notes:
834
+ Uses CURRENT_TIMESTAMP for timestamp if not provided in kwargs.
835
+ JSONB fields are wrapped with Jsonb() for PostgreSQL type safety.
836
+ """
837
+ content_json = Jsonb(content) if content is not None else None
838
+ grounding_metadata = kwargs.get("grounding_metadata")
839
+ grounding_metadata_json = Jsonb(grounding_metadata) if grounding_metadata is not None else None
840
+ custom_metadata = kwargs.get("custom_metadata")
841
+ custom_metadata_json = Jsonb(custom_metadata) if custom_metadata is not None else None
842
+
843
+ query = pg_sql.SQL("""
844
+ INSERT INTO {table} (
845
+ id, session_id, app_name, user_id, invocation_id, author, actions,
846
+ long_running_tool_ids_json, branch, timestamp, content,
847
+ grounding_metadata, custom_metadata, partial, turn_complete,
848
+ interrupted, error_code, error_message
849
+ ) VALUES (
850
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, COALESCE(%s, CURRENT_TIMESTAMP), %s, %s, %s, %s, %s, %s, %s, %s
851
+ )
852
+ RETURNING id, session_id, app_name, user_id, invocation_id, author, actions,
853
+ long_running_tool_ids_json, branch, timestamp, content,
854
+ grounding_metadata, custom_metadata, partial, turn_complete,
855
+ interrupted, error_code, error_message
856
+ """).format(table=pg_sql.Identifier(self._events_table))
857
+
858
+ with self._config.provide_connection() as conn, conn.cursor() as cur:
859
+ cur.execute(
860
+ query,
861
+ (
862
+ event_id,
863
+ session_id,
864
+ app_name,
865
+ user_id,
866
+ kwargs.get("invocation_id"),
867
+ author,
868
+ actions,
869
+ kwargs.get("long_running_tool_ids_json"),
870
+ kwargs.get("branch"),
871
+ kwargs.get("timestamp"),
872
+ content_json,
873
+ grounding_metadata_json,
874
+ custom_metadata_json,
875
+ kwargs.get("partial"),
876
+ kwargs.get("turn_complete"),
877
+ kwargs.get("interrupted"),
878
+ kwargs.get("error_code"),
879
+ kwargs.get("error_message"),
880
+ ),
881
+ )
882
+ row = cur.fetchone()
883
+
884
+ if row is None:
885
+ msg = f"Failed to create event {event_id}"
886
+ raise RuntimeError(msg)
887
+
888
+ return EventRecord(
889
+ id=row["id"],
890
+ session_id=row["session_id"],
891
+ app_name=row["app_name"],
892
+ user_id=row["user_id"],
893
+ invocation_id=row["invocation_id"],
894
+ author=row["author"],
895
+ actions=bytes(row["actions"]) if row["actions"] else b"",
896
+ long_running_tool_ids_json=row["long_running_tool_ids_json"],
897
+ branch=row["branch"],
898
+ timestamp=row["timestamp"],
899
+ content=row["content"],
900
+ grounding_metadata=row["grounding_metadata"],
901
+ custom_metadata=row["custom_metadata"],
902
+ partial=row["partial"],
903
+ turn_complete=row["turn_complete"],
904
+ interrupted=row["interrupted"],
905
+ error_code=row["error_code"],
906
+ error_message=row["error_message"],
907
+ )
908
+
909
+ def list_events(self, session_id: str) -> "list[EventRecord]":
910
+ """List events for a session ordered by timestamp.
911
+
912
+ Args:
913
+ session_id: Session identifier.
914
+
915
+ Returns:
916
+ List of event records ordered by timestamp ASC.
917
+
918
+ Notes:
919
+ Uses index on (session_id, timestamp ASC).
920
+ JSONB fields are automatically deserialized by psycopg.
921
+ BYTEA actions are converted to bytes.
922
+ """
923
+ query = pg_sql.SQL("""
924
+ SELECT id, session_id, app_name, user_id, invocation_id, author, actions,
925
+ long_running_tool_ids_json, branch, timestamp, content,
926
+ grounding_metadata, custom_metadata, partial, turn_complete,
927
+ interrupted, error_code, error_message
928
+ FROM {table}
929
+ WHERE session_id = %s
930
+ ORDER BY timestamp ASC
931
+ """).format(table=pg_sql.Identifier(self._events_table))
932
+
933
+ try:
934
+ with self._config.provide_connection() as conn, conn.cursor() as cur:
935
+ cur.execute(query, (session_id,))
936
+ rows = cur.fetchall()
937
+
938
+ return [
939
+ EventRecord(
940
+ id=row["id"],
941
+ session_id=row["session_id"],
942
+ app_name=row["app_name"],
943
+ user_id=row["user_id"],
944
+ invocation_id=row["invocation_id"],
945
+ author=row["author"],
946
+ actions=bytes(row["actions"]) if row["actions"] else b"",
947
+ long_running_tool_ids_json=row["long_running_tool_ids_json"],
948
+ branch=row["branch"],
949
+ timestamp=row["timestamp"],
950
+ content=row["content"],
951
+ grounding_metadata=row["grounding_metadata"],
952
+ custom_metadata=row["custom_metadata"],
953
+ partial=row["partial"],
954
+ turn_complete=row["turn_complete"],
955
+ interrupted=row["interrupted"],
956
+ error_code=row["error_code"],
957
+ error_message=row["error_message"],
958
+ )
959
+ for row in rows
960
+ ]
961
+ except errors.UndefinedTable:
962
+ return []