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.
- orchid_storage_postgres-1.0.0/.github/workflows/ci.yml +156 -0
- orchid_storage_postgres-1.0.0/.pre-commit-config.yaml +11 -0
- orchid_storage_postgres-1.0.0/AGENTS.md +59 -0
- orchid_storage_postgres-1.0.0/CHANGELOG.md +12 -0
- orchid_storage_postgres-1.0.0/LICENSE +21 -0
- orchid_storage_postgres-1.0.0/PKG-INFO +74 -0
- orchid_storage_postgres-1.0.0/README.md +47 -0
- orchid_storage_postgres-1.0.0/orchid_storage_postgres/__init__.py +48 -0
- orchid_storage_postgres-1.0.0/orchid_storage_postgres/chat_storage.py +249 -0
- orchid_storage_postgres-1.0.0/orchid_storage_postgres/migrations/__init__.py +44 -0
- orchid_storage_postgres-1.0.0/orchid_storage_postgres/migrations/v001_initial_schema.py +48 -0
- orchid_storage_postgres-1.0.0/orchid_storage_postgres/visibility.py +35 -0
- orchid_storage_postgres-1.0.0/pyproject.toml +74 -0
- orchid_storage_postgres-1.0.0/tests/test_chat_storage.py +226 -0
- orchid_storage_postgres-1.0.0/tests/test_checkpointer.py +30 -0
- orchid_storage_postgres-1.0.0/tests/test_migration.py +54 -0
- orchid_storage_postgres-1.0.0/tests/test_postgres_queue.py +24 -0
- orchid_storage_postgres-1.0.0/tests/test_visibility.py +30 -0
|
@@ -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
|