orchid-storage-postgres 1.0.0__tar.gz

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,156 @@
1
+ # orchid-storage-postgres — CI + Semantic Release + PyPI Publish
2
+
3
+ name: CI
4
+
5
+ on:
6
+ pull_request:
7
+ branches: [main]
8
+ push:
9
+ branches: [main]
10
+
11
+ jobs:
12
+ # ── Lint ────────────────────────────────────────────────────
13
+
14
+ ruff:
15
+ name: Ruff lint & format
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.13"
22
+ - run: pip install ruff
23
+ - run: ruff check orchid_storage_postgres/
24
+ - run: ruff format --check orchid_storage_postgres/
25
+
26
+ # ── Test ────────────────────────────────────────────────────
27
+
28
+ test:
29
+ name: Tests + coverage
30
+ runs-on: ubuntu-latest
31
+ needs: [ruff]
32
+ permissions:
33
+ contents: read
34
+ pull-requests: write
35
+ strategy:
36
+ matrix:
37
+ python-version: ['3.11', '3.12', '3.13']
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: actions/setup-python@v5
41
+ with:
42
+ python-version: ${{ matrix.python-version }}
43
+ cache: pip
44
+ cache-dependency-path: pyproject.toml
45
+ - run: pip install -e ".[dev]" pytest-cov
46
+ - name: Run tests
47
+ run: |
48
+ pytest tests/ \
49
+ --cov=orchid_storage_postgres \
50
+ --cov-report=term-missing \
51
+ --cov-report=xml:coverage.xml \
52
+ --junitxml=report.xml \
53
+ -x
54
+ - name: Upload coverage
55
+ if: always()
56
+ uses: actions/upload-artifact@v4
57
+ with:
58
+ name: coverage
59
+ path: coverage.xml
60
+ if-no-files-found: ignore
61
+ - name: Upload test report
62
+ if: always()
63
+ uses: actions/upload-artifact@v4
64
+ with:
65
+ name: test-report
66
+ path: report.xml
67
+ if-no-files-found: ignore
68
+ - name: Coverage summary
69
+ if: hashFiles('coverage.xml') != ''
70
+ uses: irongut/CodeCoverageSummary@v1.3.0
71
+ with:
72
+ filename: "**/coverage.xml"
73
+ format: markdown
74
+ output: both
75
+ badge: true
76
+ fail_below_min: false
77
+ thresholds: "50 75"
78
+ - name: Add coverage PR comment
79
+ if: github.event_name == 'pull_request' && hashFiles('code-coverage-results.md') != ''
80
+ uses: marocchino/sticky-pull-request-comment@v2
81
+ with:
82
+ header: coverage
83
+ path: code-coverage-results.md
84
+
85
+ # ── Build ───────────────────────────────────────────────────
86
+
87
+ build:
88
+ name: Build wheel
89
+ runs-on: ubuntu-latest
90
+ needs: [ruff, test]
91
+ steps:
92
+ - uses: actions/checkout@v4
93
+ - uses: actions/setup-python@v5
94
+ with:
95
+ python-version: "3.13"
96
+ - run: pip install build
97
+ - run: python -m build
98
+ - uses: actions/upload-artifact@v4
99
+ with:
100
+ name: dist
101
+ path: dist/
102
+ retention-days: 7
103
+
104
+ # ── Release + PyPI Publish (on main push) ───────────────────
105
+
106
+ release:
107
+ name: Release & publish
108
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
109
+ runs-on: ubuntu-latest
110
+ needs: [ruff, test, build]
111
+ permissions:
112
+ contents: write
113
+ id-token: write
114
+ steps:
115
+ - uses: actions/checkout@v4
116
+ with:
117
+ fetch-depth: 0
118
+ token: ${{ secrets.GITHUB_TOKEN }}
119
+ - uses: actions/setup-python@v5
120
+ with:
121
+ python-version: "3.13"
122
+ - run: pip install python-semantic-release build
123
+ - name: Configure git
124
+ run: |
125
+ git config user.name "github-actions[bot]"
126
+ git config user.email "github-actions[bot]@users.noreply.github.com"
127
+ - name: Bump version & tag
128
+ id: version
129
+ env:
130
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
131
+ run: |
132
+ before=$(python -c "import re,pathlib; c=pathlib.Path('orchid_storage_postgres/__init__.py').read_text(); m=re.search(r'__version__\s*=\s*\"([^\"]+)\"', c); print(m.group(1))")
133
+ semantic-release version --push --tag --commit || true
134
+ after=$(python -c "import re,pathlib; c=pathlib.Path('orchid_storage_postgres/__init__.py').read_text(); m=re.search(r'__version__\s*=\s*\"([^\"]+)\"', c); print(m.group(1))")
135
+ echo "before=$before"
136
+ echo "after=$after"
137
+ if [ "$before" = "$after" ]; then
138
+ echo "No version bump — skipping publish steps."
139
+ echo "released=false" >> "$GITHUB_OUTPUT"
140
+ else
141
+ echo "Bumped $before → $after."
142
+ echo "released=true" >> "$GITHUB_OUTPUT"
143
+ fi
144
+ - name: Build distribution
145
+ if: steps.version.outputs.released == 'true'
146
+ run: python -m build
147
+ - name: Publish GitHub Release
148
+ if: steps.version.outputs.released == 'true'
149
+ env:
150
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
151
+ run: semantic-release publish
152
+ - name: Publish to PyPI
153
+ if: steps.version.outputs.released == 'true'
154
+ uses: pypa/gh-action-pypi-publish@release/v1
155
+ with:
156
+ skip-existing: true
@@ -0,0 +1,11 @@
1
+ # Pre-commit hooks — run automatically before each commit
2
+ # Install: pip install pre-commit && pre-commit install
3
+ # Manual: pre-commit run --all-files
4
+
5
+ repos:
6
+ - repo: https://github.com/astral-sh/ruff-pre-commit
7
+ rev: v0.9.10
8
+ hooks:
9
+ - id: ruff
10
+ args: [--fix, --exit-non-zero-on-fix]
11
+ - id: ruff-format
@@ -0,0 +1,59 @@
1
+ # orchid-storage-postgres — AI Context
2
+
3
+ ## What This Package Is
4
+
5
+ `orchid-storage-postgres` is the PostgreSQL storage plugin for the Orchid AI
6
+ framework. It provides:
7
+
8
+ - `OrchidPostgresChatStorage` — implements `OrchidChatStorage` backed by asyncpg
9
+ - PostgreSQL schema migration (v001) with all framework-owned tables
10
+ - PostgreSQL visibility fragment for `build_run_filter_clause` ($1..$N params)
11
+
12
+ ## Auto-Registration
13
+
14
+ The package registers itself via Python `importlib.metadata` entry points:
15
+
16
+ ```toml
17
+ [project.entry-points."orchid.visibility_fragments"]
18
+ postgres = "orchid_storage_postgres:_register"
19
+ ```
20
+
21
+ No manual `register_visibility_fragment()` calls are needed by integrators.
22
+
23
+ The chat storage is NOT auto-registered — consumers reference it via
24
+ dotted class path in their YAML config:
25
+
26
+ ```yaml
27
+ storage:
28
+ class: orchid_storage_postgres.chat_storage.OrchidPostgresChatStorage
29
+ dsn: postgresql://...
30
+ ```
31
+
32
+ ## Key Files
33
+
34
+ | File | Purpose |
35
+ |------|---------|
36
+ | `chat_storage.py` | `OrchidPostgresChatStorage` + row mappers |
37
+ | `migrations.py` | `PostgresMigrationRunner` + v001 DDL |
38
+ | `visibility.py` | `_build_postgres_filter` (asyncpg $1..$N params) |
39
+ | `__init__.py` | Entry-point `_register()` callable |
40
+
41
+ ## Testing
42
+
43
+ Tests require `asyncpg` but do **not** require a live PostgreSQL server —
44
+ all unit tests mock `asyncpg.create_pool`.
45
+
46
+ ```bash
47
+ cd orchid-storage-postgres
48
+ pip install -e ".[dev]"
49
+ pytest tests/ -x
50
+ ```
51
+
52
+ ## Common Pitfalls
53
+
54
+ - `asyncpg` uses `$1..$N` positional parameters, NOT `%s` or `:name` style.
55
+ - `agents_used` and `metadata` columns in `chat_messages` are stored as
56
+ `JSONB` objects in PostgreSQL. The row mapper handles both string and
57
+ already-parsed representations.
58
+ - The plugin schema migration creates ALL framework-owned tables
59
+ (chat, MCP, events), not just chat tables.
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [1.0.0] - TBD
6
+
7
+ ### Added
8
+
9
+ - `OrchidPostgresChatStorage` extracted from `orchid-ai` core.
10
+ - PostgreSQL schema migration (v001) with all framework-owned tables.
11
+ - PostgreSQL visibility fragment for `build_run_filter_clause`.
12
+ - Auto-registration for `orchid.visibility_fragments` entry-point group.
@@ -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.
@@ -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,47 @@
1
+ # orchid-storage-postgres
2
+
3
+ PostgreSQL storage backend plugin for the [Orchid AI](https://github.com/gadz82/orchid) framework.
4
+
5
+ ## What it provides
6
+
7
+ - `OrchidPostgresChatStorage` — implements `OrchidChatStorage` backed by PostgreSQL (asyncpg)
8
+ - PostgreSQL visibility fragment for `build_run_filter_clause`
9
+ - PostgreSQL schema migration (v001)
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install orchid-storage-postgres
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Reference in your `orchid.yml`:
20
+
21
+ ```yaml
22
+ storage:
23
+ class: orchid_storage_postgres.chat_storage.OrchidPostgresChatStorage
24
+ dsn: postgresql://user:pass@localhost:5432/orchid
25
+ ```
26
+
27
+ Or build it programmatically:
28
+
29
+ ```python
30
+ from orchid_storage_postgres import OrchidPostgresChatStorage
31
+
32
+ storage = OrchidPostgresChatStorage(dsn="postgresql://user:pass@localhost:5432/orchid")
33
+ await storage.init_db()
34
+ ```
35
+
36
+ ## Development
37
+
38
+ ```bash
39
+ cd orchid-storage-postgres
40
+ pip install -e ".[dev]"
41
+ pytest tests/ -x
42
+ ruff check orchid_storage_postgres/
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
@@ -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
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "orchid-storage-postgres"
7
+ description = "PostgreSQL storage backend plugin for the Orchid AI framework"
8
+ readme = "README.md"
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.11"
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+ keywords = ["ai", "agents", "storage", "postgresql", "asyncpg"]
21
+ dependencies = [
22
+ "orchid-ai>=1.8.2",
23
+ "asyncpg>=0.29.0",
24
+ "langgraph-checkpoint-postgres>=2.0.0",
25
+ ]
26
+ dynamic = ["version"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "pytest-asyncio>=0.25.0",
32
+ "pytest-cov>=6.0",
33
+ "ruff>=0.9.0",
34
+ ]
35
+
36
+ [project.entry-points."orchid.visibility_fragments"]
37
+ postgres = "orchid_storage_postgres:_register"
38
+
39
+ [project.entry-points."orchid.checkpointers"]
40
+ postgres = "orchid_storage_postgres:_register"
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/gadz82/orchid-storage-postgres"
44
+ Issues = "https://github.com/gadz82/orchid-storage-postgres/issues"
45
+
46
+ [tool.hatch.version]
47
+ path = "orchid_storage_postgres/__init__.py"
48
+
49
+ [tool.ruff]
50
+ target-version = "py311"
51
+ line-length = 120
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "strict"
55
+ testpaths = ["tests"]
56
+
57
+ [tool.semantic_release]
58
+ version_variables = ["orchid_storage_postgres/__init__.py:__version__"]
59
+ branch = "main"
60
+ commit_message = "chore(release): v{version} [skip ci]"
61
+ build_command = "pip install build && python -m build"
62
+ tag_format = "v{version}"
63
+
64
+ [tool.semantic_release.publish]
65
+ upload_to_vcs_release = true
66
+ dist_glob_patterns = ["dist/*"]
67
+
68
+ [tool.semantic_release.changelog]
69
+ changelog_file = "CHANGELOG.md"
70
+
71
+ [tool.semantic_release.commit_parser_options]
72
+ allowed_tags = ["feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore"]
73
+ minor_tags = ["feat"]
74
+ patch_tags = ["fix", "perf"]
@@ -0,0 +1,226 @@
1
+ """Tests for ``OrchidPostgresChatStorage`` against a mocked ``asyncpg`` pool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from unittest.mock import AsyncMock, MagicMock, patch
8
+
9
+ import pytest
10
+
11
+ from orchid_ai.persistence.models import OrchidChatMessage, OrchidChatSession
12
+
13
+ from orchid_storage_postgres.chat_storage import OrchidPostgresChatStorage, _row_to_message, _row_to_session
14
+
15
+
16
+ # ── Helpers ─────────────────────────────────────────────────
17
+
18
+
19
+ def _mock_pool() -> MagicMock:
20
+ pool = MagicMock()
21
+ conn = AsyncMock()
22
+ conn.execute = AsyncMock()
23
+ conn.fetch = AsyncMock(return_value=[])
24
+ conn.fetchrow = AsyncMock(return_value=None)
25
+
26
+ # Support ``async with pool.acquire() as conn``
27
+ async def _acquire():
28
+ return conn
29
+
30
+ pool.acquire.return_value.__aenter__ = AsyncMock(side_effect=_acquire)
31
+ pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False)
32
+ pool.execute = AsyncMock()
33
+ return pool
34
+
35
+
36
+ async def _init_store(store: OrchidPostgresChatStorage, pool: MagicMock) -> OrchidPostgresChatStorage:
37
+ with patch.object(store, "init_db", AsyncMock()) as mock_init:
38
+ mock_init.side_effect = None # real init_db would set _pool
39
+ store._pool = pool
40
+ return store
41
+
42
+
43
+ # ── Construction ──────────────────────────────────────────────
44
+
45
+
46
+ class TestConstruction:
47
+ @pytest.mark.asyncio
48
+ async def test_missing_driver_raises_import_error(self):
49
+ with patch.dict("sys.modules", {"asyncpg": None}):
50
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
51
+ with pytest.raises(ImportError):
52
+ await store.init_db()
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_init_db_creates_pool(self):
56
+ mock_asyncpg = MagicMock()
57
+ mock_pool = MagicMock()
58
+ mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
59
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=AsyncMock())
60
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False)
61
+ with patch.dict("sys.modules", {"asyncpg": mock_asyncpg}):
62
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
63
+ await store.init_db()
64
+ mock_asyncpg.create_pool.assert_awaited_once()
65
+
66
+
67
+ # ── CRUD ─────────────────────────────────────────────────────
68
+
69
+
70
+ class TestChatCRUD:
71
+ @pytest.mark.asyncio
72
+ async def test_create_chat(self):
73
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
74
+ store._pool = _mock_pool()
75
+ conn = await store._pool.acquire().__aenter__()
76
+
77
+ chat = await store.create_chat("t1", "u1", "Hello")
78
+ assert chat.tenant_id == "t1"
79
+ assert chat.user_id == "u1"
80
+ assert chat.title == "Hello"
81
+ conn.execute.assert_awaited()
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_list_chats(self):
85
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
86
+ store._pool = _mock_pool()
87
+ conn = await store._pool.acquire().__aenter__()
88
+ conn.fetch = AsyncMock(return_value=[])
89
+
90
+ chats = await store.list_chats("t1", "u1")
91
+ assert chats == []
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_get_chat_returns_none_for_missing(self):
95
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
96
+ store._pool = _mock_pool()
97
+ conn = await store._pool.acquire().__aenter__()
98
+ conn.fetchrow = AsyncMock(return_value=None)
99
+
100
+ chat = await store.get_chat("missing")
101
+ assert chat is None
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_delete_chat(self):
105
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
106
+ store._pool = _mock_pool()
107
+ conn = await store._pool.acquire().__aenter__()
108
+
109
+ await store.delete_chat("c1")
110
+ conn.execute.assert_awaited()
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_update_title(self):
114
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
115
+ store._pool = _mock_pool()
116
+ conn = await store._pool.acquire().__aenter__()
117
+
118
+ await store.update_title("c1", "New Title")
119
+ conn.execute.assert_awaited()
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_mark_shared(self):
123
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
124
+ store._pool = _mock_pool()
125
+ conn = await store._pool.acquire().__aenter__()
126
+
127
+ await store.mark_shared("c1")
128
+ conn.execute.assert_awaited()
129
+
130
+
131
+ # ── Messages ─────────────────────────────────────────────────
132
+
133
+
134
+ class TestMessages:
135
+ @pytest.mark.asyncio
136
+ async def test_add_message(self):
137
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
138
+ store._pool = _mock_pool()
139
+ conn = await store._pool.acquire().__aenter__()
140
+
141
+ msg = await store.add_message("c1", "user", "hello")
142
+ assert msg.chat_id == "c1"
143
+ assert msg.role == "user"
144
+ assert msg.content == "hello"
145
+ assert conn.execute.await_count >= 2 # INSERT + UPDATE
146
+
147
+ @pytest.mark.asyncio
148
+ async def test_get_messages(self):
149
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
150
+ store._pool = _mock_pool()
151
+ conn = await store._pool.acquire().__aenter__()
152
+ conn.fetch = AsyncMock(return_value=[])
153
+
154
+ msgs = await store.get_messages("c1", limit=10)
155
+ assert msgs == []
156
+
157
+
158
+ # ── Conversation summaries ───────────────────────────────────
159
+
160
+
161
+ class TestConversationSummaries:
162
+ @pytest.mark.asyncio
163
+ async def test_get_summary_returns_none(self):
164
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
165
+ store._pool = _mock_pool()
166
+ conn = await store._pool.acquire().__aenter__()
167
+ conn.fetchrow = AsyncMock(return_value=None)
168
+
169
+ result = await store.get_conversation_summary("c1")
170
+ assert result is None
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_save_summary(self):
174
+ store = OrchidPostgresChatStorage(dsn="postgresql://localhost/db")
175
+ store._pool = _mock_pool()
176
+ conn = await store._pool.acquire().__aenter__()
177
+
178
+ await store.save_conversation_summary("c1", "Summary text", 3)
179
+ conn.execute.assert_awaited()
180
+
181
+
182
+ # ── Row mappers ──────────────────────────────────────────────
183
+
184
+
185
+ class TestRowMappers:
186
+ def test_row_to_session(self):
187
+ row = {
188
+ "id": "s1",
189
+ "tenant_id": "t1",
190
+ "user_id": "u1",
191
+ "title": "Test",
192
+ "created_at": datetime(2026, 1, 1),
193
+ "updated_at": datetime(2026, 1, 2),
194
+ "is_shared": True,
195
+ }
196
+ session = _row_to_session(row)
197
+ assert session.id == "s1"
198
+ assert session.is_shared is True
199
+
200
+ def test_row_to_message_jsonb_parsed(self):
201
+ row = {
202
+ "id": "m1",
203
+ "chat_id": "c1",
204
+ "role": "assistant",
205
+ "content": "Hi there",
206
+ "agents_used": json.dumps(["agent-a"]),
207
+ "created_at": datetime(2026, 1, 1),
208
+ "metadata": json.dumps({"k": "v"}),
209
+ }
210
+ msg = _row_to_message(row)
211
+ assert msg.agents_used == ["agent-a"]
212
+ assert msg.metadata == {"k": "v"}
213
+
214
+ def test_row_to_message_already_parsed(self):
215
+ row = {
216
+ "id": "m1",
217
+ "chat_id": "c1",
218
+ "role": "assistant",
219
+ "content": "Hi",
220
+ "agents_used": ["agent-a"],
221
+ "created_at": datetime(2026, 1, 1),
222
+ "metadata": {"k": "v"},
223
+ }
224
+ msg = _row_to_message(row)
225
+ assert msg.agents_used == ["agent-a"]
226
+ assert msg.metadata == {"k": "v"}
@@ -0,0 +1,30 @@
1
+ """Tests for the PostgreSQL checkpointer (bundled in orchid-storage-postgres)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, patch
6
+
7
+ import pytest
8
+
9
+
10
+ class TestBuildPostgresCheckpointer:
11
+ @pytest.mark.asyncio
12
+ async def test_factory_in_module(self):
13
+ from orchid_storage_postgres import _build_postgres_checkpointer
14
+
15
+ assert callable(_build_postgres_checkpointer)
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_missing_package_raises_import_error(self):
19
+ with patch.dict("sys.modules", {"langgraph.checkpoint.postgres": None}):
20
+ with pytest.raises(ImportError):
21
+ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver # noqa: F401
22
+
23
+
24
+ class TestRegistration:
25
+ def test_register_adds_to_registry(self):
26
+ from orchid_ai.checkpointing.factory import _CHECKPOINTER_REGISTRY
27
+ from orchid_storage_postgres import _register
28
+
29
+ _register()
30
+ assert "postgres" in _CHECKPOINTER_REGISTRY
@@ -0,0 +1,54 @@
1
+ """Tests for the PostgreSQL migration runner and v001 migration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock
6
+
7
+ import pytest
8
+
9
+ from orchid_storage_postgres.migrations import PostgresMigrationRunner
10
+ from orchid_storage_postgres.migrations.v001_initial_schema import VERSION, up
11
+
12
+
13
+ class TestPostgresMigrationRunner:
14
+ @pytest.mark.asyncio
15
+ async def test_ensure_migrations_table(self):
16
+ runner = PostgresMigrationRunner()
17
+ conn = AsyncMock()
18
+ await runner.ensure_migrations_table(conn)
19
+ conn.execute.assert_awaited_once()
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_get_applied_versions(self):
23
+ runner = PostgresMigrationRunner()
24
+ conn = AsyncMock()
25
+ conn.fetch = AsyncMock(return_value=[])
26
+ versions = await runner.get_applied_versions(conn)
27
+ assert versions == set()
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_record_version(self):
31
+ runner = PostgresMigrationRunner()
32
+ conn = AsyncMock()
33
+ await runner.record_version(conn, "001", "test")
34
+ conn.execute.assert_awaited_once()
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_remove_version(self):
38
+ runner = PostgresMigrationRunner()
39
+ conn = AsyncMock()
40
+ await runner.remove_version(conn, "001")
41
+ conn.execute.assert_awaited_once()
42
+
43
+
44
+ class TestV001Migration:
45
+ @pytest.mark.asyncio
46
+ async def test_up_executes_statements(self):
47
+ conn = AsyncMock()
48
+ await up(conn)
49
+ # PG_UP has many statements — verify at least some executed
50
+ assert conn.execute.await_count > 0
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_has_version(self):
54
+ assert VERSION == "001"
@@ -0,0 +1,24 @@
1
+ """Postgres-backed queue + event-store tests.
2
+
3
+ These tests require a live Postgres reachable at the DSN supplied via
4
+ ``ORCHID_TEST_PG_DSN``. They're skipped when the variable is unset
5
+ so CI can stay hermetic; the docker-compose Postgres is what wires
6
+ them up locally::
7
+
8
+ export ORCHID_TEST_PG_DSN=postgresql://orchid:orchid@localhost:5432/orchid_test
9
+ cd orchid && .venv/bin/python -m pytest tests/events/test_postgres_queue.py
10
+
11
+ The fixture isolates each test in a fresh schema and rolls back at
12
+ teardown so tests don't leak rows across runs.
13
+
14
+ .. note::
15
+
16
+ ``PostgresEventStorage`` and ``PostgresSignalQueue`` are not yet
17
+ available in the orchid-storage-postgres plugin. These tests are
18
+ skipped unconditionally until the postgres events plugin ships.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import pytest
23
+
24
+ pytestmark = pytest.mark.skip(reason="PostgresEventStorage / PostgresSignalQueue not yet available in orchid-storage-postgres plugin")
@@ -0,0 +1,30 @@
1
+ """Tests for the PostgreSQL visibility fragment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ from orchid_storage_postgres.visibility import _build_postgres_filter
8
+
9
+
10
+ class TestPostgresVisibilityFilter:
11
+ def test_admin_short_circuits_to_tenant_only(self):
12
+ auth = SimpleNamespace(tenant_key="t1", user_id="u1", roles=frozenset(["admin"]))
13
+ f = _build_postgres_filter(auth)
14
+ assert "tenant_key = $1" in f.where
15
+ assert f.params["tenant_key"] == "t1"
16
+
17
+ def test_non_admin_gets_visibility_clauses(self):
18
+ auth = SimpleNamespace(tenant_key="t1", user_id="u1", roles=frozenset())
19
+ f = _build_postgres_filter(auth)
20
+ assert "tenant_key = $1" in f.where
21
+ assert "visibility = 'tenant'" in f.where
22
+ assert "visibility_user_id = $2" in f.where
23
+ assert f.params["tenant_key"] == "t1"
24
+ assert f.params["user_id"] == "u1"
25
+
26
+ def test_uses_positional_params(self):
27
+ auth = SimpleNamespace(tenant_key="t1", user_id="u1", roles=frozenset())
28
+ f = _build_postgres_filter(auth)
29
+ assert "$1" in f.where
30
+ assert "$2" in f.where