orchid-storage-postgres 1.0.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.
@@ -0,0 +1,48 @@
1
+ """PostgreSQL storage plugin for the Orchid AI framework.
2
+
3
+ Provides ``OrchidPostgresChatStorage``, a PostgreSQL visibility
4
+ fragment, and a PostgreSQL checkpointer. Auto-registers via
5
+ ``importlib.metadata`` entry points.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ __version__ = "1.0.0"
13
+
14
+ from .chat_storage import OrchidPostgresChatStorage
15
+ from .visibility import _build_postgres_filter
16
+
17
+ __all__ = ["OrchidPostgresChatStorage"]
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def _build_postgres_checkpointer(dsn: str):
23
+ """Build an async PostgreSQL checkpointer from a DSN."""
24
+ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
25
+
26
+ checkpointer = AsyncPostgresSaver.from_conn_string(dsn)
27
+ await checkpointer.setup()
28
+ logger.info("[orchid-storage-postgres] Checkpointer ready")
29
+ return checkpointer
30
+
31
+
32
+ def _register() -> None:
33
+ """Entry-point callable — registers the postgres visibility fragment and checkpointer."""
34
+ try:
35
+ from orchid_ai.events.visibility import register_visibility_fragment
36
+
37
+ register_visibility_fragment("postgres", _build_postgres_filter)
38
+ logger.debug("[orchid-storage-postgres] Registered visibility fragment")
39
+ except ImportError:
40
+ logger.debug("[orchid-storage-postgres] Skipping visibility fragment (not in this orchid-ai version)")
41
+
42
+ try:
43
+ from orchid_ai.checkpointing.factory import register_checkpointer
44
+
45
+ register_checkpointer("postgres", _build_postgres_checkpointer)
46
+ logger.debug("[orchid-storage-postgres] Registered checkpointer")
47
+ except ImportError:
48
+ logger.debug("[orchid-storage-postgres] Skipping checkpointer (not in this orchid-ai version)")
@@ -0,0 +1,249 @@
1
+ """
2
+ PostgreSQL chat storage — production-grade :class:`OrchidChatStorage` backend.
3
+
4
+ Backed by ``asyncpg`` with connection pooling. Implements every
5
+ :class:`OrchidChatStorage` method using ``$1..$N`` placeholders.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import uuid
13
+ from datetime import datetime
14
+ from typing import Any
15
+
16
+ from orchid_ai.persistence.base import OrchidChatStorage
17
+ from orchid_ai.persistence.models import OrchidChatMessage, OrchidChatSession, utcnow
18
+
19
+ from .migrations import PostgresMigrationRunner
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class OrchidPostgresChatStorage(OrchidChatStorage):
25
+ """Async PostgreSQL storage for chat sessions and messages.
26
+
27
+ Constructor accepts the DSN via ``dsn`` and an optional
28
+ ``extra_migrations_package`` (dotted import path) so integrators
29
+ can append their own migrations after the framework's.
30
+ """
31
+
32
+ def __init__(self, *, dsn: str, extra_migrations_package: str | None = None):
33
+ self._dsn = dsn
34
+ self._pool: Any = None
35
+ self._migrator = PostgresMigrationRunner(
36
+ extra_migrations_package=extra_migrations_package,
37
+ )
38
+
39
+ # ── Lifecycle ────────────────────────────────────────────
40
+
41
+ async def init_db(self) -> None:
42
+ import asyncpg
43
+
44
+ self._pool = await asyncpg.create_pool(self._dsn, min_size=1, max_size=10)
45
+ async with self._pool.acquire() as conn:
46
+ await self._migrator.run_up(conn)
47
+ logger.info("[OrchidChatStorage:postgres] Initialised")
48
+
49
+ async def close(self) -> None:
50
+ if self._pool:
51
+ await self._pool.close()
52
+ self._pool = None
53
+
54
+ async def _conn(self):
55
+ if self._pool is None:
56
+ raise RuntimeError("OrchidPostgresChatStorage: init_db() not called")
57
+ return await self._pool.acquire()
58
+
59
+ # ── Sessions ─────────────────────────────────────────────
60
+
61
+ async def create_chat(
62
+ self,
63
+ tenant_id: str,
64
+ user_id: str,
65
+ title: str = "",
66
+ ) -> OrchidChatSession:
67
+ now = utcnow()
68
+ chat = OrchidChatSession(
69
+ id=str(uuid.uuid4()),
70
+ tenant_id=tenant_id,
71
+ user_id=user_id,
72
+ title=title or "New chat",
73
+ created_at=now,
74
+ updated_at=now,
75
+ )
76
+ async with self._pool.acquire() as conn:
77
+ await conn.execute(
78
+ "INSERT INTO chat_sessions (id, tenant_id, user_id, title, created_at, updated_at) "
79
+ "VALUES ($1, $2, $3, $4, $5, $6)",
80
+ chat.id,
81
+ chat.tenant_id,
82
+ chat.user_id,
83
+ chat.title,
84
+ now,
85
+ now,
86
+ )
87
+ return chat
88
+
89
+ async def list_chats(
90
+ self,
91
+ tenant_id: str,
92
+ user_id: str,
93
+ ) -> list[OrchidChatSession]:
94
+ async with self._pool.acquire() as conn:
95
+ rows = await conn.fetch(
96
+ "SELECT * FROM chat_sessions WHERE tenant_id = $1 AND user_id = $2 ORDER BY updated_at DESC",
97
+ tenant_id,
98
+ user_id,
99
+ )
100
+ return [_row_to_session(r) for r in rows]
101
+
102
+ async def get_chat(self, chat_id: str) -> OrchidChatSession | None:
103
+ async with self._pool.acquire() as conn:
104
+ row = await conn.fetchrow(
105
+ "SELECT * FROM chat_sessions WHERE id = $1",
106
+ chat_id,
107
+ )
108
+ return _row_to_session(row) if row else None
109
+
110
+ async def delete_chat(self, chat_id: str) -> None:
111
+ async with self._pool.acquire() as conn:
112
+ await conn.execute("DELETE FROM chat_sessions WHERE id = $1", chat_id)
113
+
114
+ async def update_title(self, chat_id: str, title: str) -> None:
115
+ async with self._pool.acquire() as conn:
116
+ await conn.execute(
117
+ "UPDATE chat_sessions SET title = $1, updated_at = $2 WHERE id = $3",
118
+ title,
119
+ utcnow(),
120
+ chat_id,
121
+ )
122
+
123
+ async def mark_shared(self, chat_id: str) -> None:
124
+ async with self._pool.acquire() as conn:
125
+ await conn.execute(
126
+ "UPDATE chat_sessions SET is_shared = TRUE, updated_at = $1 WHERE id = $2",
127
+ utcnow(),
128
+ chat_id,
129
+ )
130
+
131
+ # ── Messages ─────────────────────────────────────────────
132
+
133
+ async def add_message(
134
+ self,
135
+ chat_id: str,
136
+ role: str,
137
+ content: str,
138
+ agents_used: list[str] | None = None,
139
+ metadata: dict | None = None,
140
+ ) -> OrchidChatMessage:
141
+ now = utcnow()
142
+ msg = OrchidChatMessage(
143
+ id=str(uuid.uuid4()),
144
+ chat_id=chat_id,
145
+ role=role,
146
+ content=content,
147
+ agents_used=agents_used or [],
148
+ created_at=now,
149
+ metadata=metadata or {},
150
+ )
151
+ async with self._pool.acquire() as conn:
152
+ await conn.execute(
153
+ "INSERT INTO chat_messages (id, chat_id, role, content, agents_used, created_at, metadata) "
154
+ "VALUES ($1, $2, $3, $4, $5, $6, $7)",
155
+ msg.id,
156
+ msg.chat_id,
157
+ msg.role,
158
+ msg.content,
159
+ json.dumps(msg.agents_used),
160
+ now,
161
+ json.dumps(msg.metadata),
162
+ )
163
+ await conn.execute(
164
+ "UPDATE chat_sessions SET updated_at = $1 WHERE id = $2",
165
+ now,
166
+ chat_id,
167
+ )
168
+ return msg
169
+
170
+ async def get_messages(
171
+ self,
172
+ chat_id: str,
173
+ limit: int = 50,
174
+ offset: int = 0,
175
+ ) -> list[OrchidChatMessage]:
176
+ async with self._pool.acquire() as conn:
177
+ rows = await conn.fetch(
178
+ "SELECT * FROM chat_messages WHERE chat_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
179
+ chat_id,
180
+ limit,
181
+ offset,
182
+ )
183
+ return [_row_to_message(r) for r in rows]
184
+
185
+ # ── Conversation summaries ───────────────────────────────
186
+
187
+ async def get_conversation_summary(self, chat_id: str) -> str | None:
188
+ async with self._pool.acquire() as conn:
189
+ row = await conn.fetchrow(
190
+ "SELECT summary_text FROM conversation_summaries WHERE chat_id = $1",
191
+ chat_id,
192
+ )
193
+ return row["summary_text"] if row else None
194
+
195
+ async def save_conversation_summary(self, chat_id: str, summary: str, turn_number: int) -> None:
196
+ async with self._pool.acquire() as conn:
197
+ await conn.execute(
198
+ "INSERT INTO conversation_summaries (chat_id, summary_text, turn_number, updated_at) "
199
+ "VALUES ($1, $2, $3, $4) "
200
+ "ON CONFLICT (chat_id) DO UPDATE SET summary_text = $2, turn_number = $3, updated_at = $4",
201
+ chat_id,
202
+ summary,
203
+ turn_number,
204
+ utcnow(),
205
+ )
206
+
207
+
208
+ # ── Row mappers ──────────────────────────────────────────────
209
+
210
+
211
+ def _parse_dt(val: Any) -> datetime:
212
+ if isinstance(val, datetime):
213
+ return val
214
+ if isinstance(val, str):
215
+ try:
216
+ return datetime.fromisoformat(val)
217
+ except (ValueError, TypeError):
218
+ return utcnow()
219
+ return utcnow()
220
+
221
+
222
+ def _row_to_session(row: Any) -> OrchidChatSession:
223
+ return OrchidChatSession(
224
+ id=row["id"],
225
+ tenant_id=row["tenant_id"],
226
+ user_id=row["user_id"],
227
+ title=row["title"],
228
+ created_at=_parse_dt(row["created_at"]),
229
+ updated_at=_parse_dt(row["updated_at"]),
230
+ is_shared=bool(row["is_shared"]),
231
+ )
232
+
233
+
234
+ def _row_to_message(row: Any) -> OrchidChatMessage:
235
+ agents_used = row["agents_used"]
236
+ if isinstance(agents_used, str):
237
+ agents_used = json.loads(agents_used)
238
+ meta = row["metadata"]
239
+ if isinstance(meta, str):
240
+ meta = json.loads(meta)
241
+ return OrchidChatMessage(
242
+ id=row["id"],
243
+ chat_id=row["chat_id"],
244
+ role=row["role"],
245
+ content=row["content"],
246
+ agents_used=agents_used or [],
247
+ created_at=_parse_dt(row["created_at"]),
248
+ metadata=meta or {},
249
+ )
@@ -0,0 +1,44 @@
1
+ """
2
+ PostgreSQL migration runner.
3
+
4
+ The runner discovers migrations in ``orchid_storage_postgres.migrations``
5
+ and tracks applied versions in a ``_migrations`` table.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from orchid_ai.persistence.migrations.runner import OrchidMigrationRunner
13
+
14
+ MIGRATIONS_PACKAGE = "orchid_storage_postgres.migrations"
15
+
16
+
17
+ class PostgresMigrationRunner(OrchidMigrationRunner):
18
+ """PostgreSQL-specific migration tracking."""
19
+
20
+ dialect = "postgres"
21
+ migrations_package = MIGRATIONS_PACKAGE
22
+
23
+ async def ensure_migrations_table(self, conn: Any) -> None:
24
+ await conn.execute("""
25
+ CREATE TABLE IF NOT EXISTS _migrations (
26
+ version TEXT PRIMARY KEY,
27
+ description TEXT NOT NULL DEFAULT '',
28
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
29
+ )
30
+ """)
31
+
32
+ async def get_applied_versions(self, conn: Any) -> set[str]:
33
+ rows = await conn.fetch("SELECT version FROM _migrations")
34
+ return {r["version"] for r in rows}
35
+
36
+ async def record_version(self, conn: Any, version: str, description: str) -> None:
37
+ await conn.execute(
38
+ "INSERT INTO _migrations (version, description) VALUES ($1, $2)",
39
+ version,
40
+ description,
41
+ )
42
+
43
+ async def remove_version(self, conn: Any, version: str) -> None:
44
+ await conn.execute("DELETE FROM _migrations WHERE version = $1", version)
@@ -0,0 +1,48 @@
1
+ """
2
+ Migration v001 — PostgreSQL initial schema.
3
+
4
+ Creates every framework-owned table in a single pass.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ from orchid_ai.persistence.migrations._schema_ddl import PG_UP
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ VERSION = "001"
16
+ DESCRIPTION = "PostgreSQL initial schema (chat, MCP outbound, MCP inbound gateway, events)"
17
+
18
+ _PG_DOWN = [
19
+ "DROP TABLE IF EXISTS signal_sources CASCADE",
20
+ "DROP TABLE IF EXISTS job_runs CASCADE",
21
+ "DROP TABLE IF EXISTS schedules CASCADE",
22
+ "DROP TABLE IF EXISTS triggers CASCADE",
23
+ "DROP TABLE IF EXISTS signal_queue_dead_letter CASCADE",
24
+ "DROP TABLE IF EXISTS signal_queue CASCADE",
25
+ "DROP TABLE IF EXISTS signals CASCADE",
26
+ "DROP TABLE IF EXISTS mcp_gateway_tokens CASCADE",
27
+ "DROP TABLE IF EXISTS mcp_gateway_auth_codes CASCADE",
28
+ "DROP TABLE IF EXISTS mcp_gateway_clients CASCADE",
29
+ "DROP TABLE IF EXISTS mcp_client_registrations CASCADE",
30
+ "DROP TABLE IF EXISTS mcp_oauth_tokens CASCADE",
31
+ "DROP TABLE IF EXISTS agent_configs CASCADE",
32
+ "DROP TABLE IF EXISTS conversation_summaries CASCADE",
33
+ "DROP TABLE IF EXISTS chat_messages CASCADE",
34
+ "DROP TABLE IF EXISTS chat_sessions CASCADE",
35
+ ]
36
+
37
+
38
+ async def up(conn, *, dialect: str = "postgres") -> None:
39
+ """Apply the PostgreSQL initial schema."""
40
+ for sql in PG_UP:
41
+ await conn.execute(sql)
42
+ logger.info("[orchid-storage-postgres] Migration v001 applied (%d statements)", len(PG_UP))
43
+
44
+
45
+ async def down(conn, *, dialect: str = "postgres") -> None:
46
+ """Roll back the PostgreSQL initial schema."""
47
+ for sql in _PG_DOWN:
48
+ await conn.execute(sql)
@@ -0,0 +1,35 @@
1
+ """
2
+ PostgreSQL run-visibility filter fragment.
3
+
4
+ Registers a ``build_run_filter_clause`` implementation for the ``postgres``
5
+ dialect that uses ``$1..$N`` positional parameters (asyncpg convention).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from orchid_ai.events.visibility import _Filter # noqa: PLC2701
13
+
14
+
15
+ def _build_postgres_filter(auth: Any) -> _Filter:
16
+ """Return a ``WHERE`` fragment + positional bind params for PostgreSQL."""
17
+ tenant_key = getattr(auth, "tenant_key", "default")
18
+ user_id = getattr(auth, "user_id", "")
19
+ roles = getattr(auth, "roles", frozenset())
20
+
21
+ if "admin" in roles:
22
+ return _Filter(
23
+ where="tenant_key = $1",
24
+ params={"tenant_key": tenant_key},
25
+ )
26
+ return _Filter(
27
+ where=(
28
+ "tenant_key = $1 AND ("
29
+ "visibility = 'tenant' "
30
+ "OR (visibility IN ('actor', 'addressed') "
31
+ " AND visibility_user_id = $2)"
32
+ ")"
33
+ ),
34
+ params={"tenant_key": tenant_key, "user_id": user_id},
35
+ )
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: orchid-storage-postgres
3
+ Version: 1.0.0
4
+ Summary: PostgreSQL storage backend plugin for the Orchid AI framework
5
+ Project-URL: Homepage, https://github.com/gadz82/orchid-storage-postgres
6
+ Project-URL: Issues, https://github.com/gadz82/orchid-storage-postgres/issues
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,ai,asyncpg,postgresql,storage
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: asyncpg>=0.29.0
19
+ Requires-Dist: langgraph-checkpoint-postgres>=2.0.0
20
+ Requires-Dist: orchid-ai>=1.8.2
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.25.0; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # orchid-storage-postgres
29
+
30
+ PostgreSQL storage backend plugin for the [Orchid AI](https://github.com/gadz82/orchid) framework.
31
+
32
+ ## What it provides
33
+
34
+ - `OrchidPostgresChatStorage` — implements `OrchidChatStorage` backed by PostgreSQL (asyncpg)
35
+ - PostgreSQL visibility fragment for `build_run_filter_clause`
36
+ - PostgreSQL schema migration (v001)
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install orchid-storage-postgres
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ Reference in your `orchid.yml`:
47
+
48
+ ```yaml
49
+ storage:
50
+ class: orchid_storage_postgres.chat_storage.OrchidPostgresChatStorage
51
+ dsn: postgresql://user:pass@localhost:5432/orchid
52
+ ```
53
+
54
+ Or build it programmatically:
55
+
56
+ ```python
57
+ from orchid_storage_postgres import OrchidPostgresChatStorage
58
+
59
+ storage = OrchidPostgresChatStorage(dsn="postgresql://user:pass@localhost:5432/orchid")
60
+ await storage.init_db()
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ cd orchid-storage-postgres
67
+ pip install -e ".[dev]"
68
+ pytest tests/ -x
69
+ ruff check orchid_storage_postgres/
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,10 @@
1
+ orchid_storage_postgres/__init__.py,sha256=PA1dsUPRdVPqiGOuyTX9H_hbaKgLEQUF7qZyKmXdxr8,1699
2
+ orchid_storage_postgres/chat_storage.py,sha256=egOTQIP8EKn57WgNVqxi-DBYkDC9hy62NrvZzAuV5T4,8600
3
+ orchid_storage_postgres/visibility.py,sha256=pUAXFOQD7U6yvB39aiITj8CfVzSx-zxcZZQfzKx1pys,1057
4
+ orchid_storage_postgres/migrations/__init__.py,sha256=vApfq2cIgx41bw7O7wK7hO5ZsH4p6lad2PAc9IAe-0U,1451
5
+ orchid_storage_postgres/migrations/v001_initial_schema.py,sha256=P2g18PASFTtmSjcJcHRc6r2NaGF2cxB4Nop-RVWbxmg,1649
6
+ orchid_storage_postgres-1.0.0.dist-info/METADATA,sha256=IcnFr17TZKEu6cULXgLORBqFHo0spEWL4gndKgIl29Q,2090
7
+ orchid_storage_postgres-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ orchid_storage_postgres-1.0.0.dist-info/entry_points.txt,sha256=bIUMwNTiu6N0pbG3oKQsKOukdyLpYPrQQx2re3ChlLM,144
9
+ orchid_storage_postgres-1.0.0.dist-info/licenses/LICENSE,sha256=1xiBMms-kdU_t3A21unskfGGda1M2uemoNTaRGgtV_s,1077
10
+ orchid_storage_postgres-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [orchid.checkpointers]
2
+ postgres = orchid_storage_postgres:_register
3
+
4
+ [orchid.visibility_fragments]
5
+ postgres = orchid_storage_postgres:_register
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Francesco Marchesini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.