Skip to content

Commit 3020ac6

Browse files
michaelchuclaude
andauthored
Add PostgreSQL support and centralize data directory configuration (#176)
* Make data directory configurable via OPTOPSY_DATA_DIR env var Centralise all ~/.optopsy paths into a new paths.py module that reads OPTOPSY_DATA_DIR from the environment (defaults to ~/.optopsy). This allows production deployments (Railway, Render, etc.) to point all persistent data at a mounted volume. https://claude.ai/code/session_01DJSq82zDcsNVav4S9kgjdc * Add Railway deployment with configurable Postgres/SQLite database - Dockerfile: multi-stage uv build (python:3.13-slim) - railway.toml: Dockerfile builder with health check config - app.py: DATABASE_URL support — uses postgresql+asyncpg when set, falls back to sqlite+aiosqlite for local dev. Schema init and element mime lookup now use SQLAlchemy for both backends. - pyproject.toml: add asyncpg dependency for Postgres async driver - .dockerignore: exclude tests, .git, dev tooling from image - .env.example: document DATABASE_URL variable https://claude.ai/code/session_01DJSq82zDcsNVav4S9kgjdc * fix: address PR review comments for Postgres support and Docker hardening - Add psycopg2-binary dep and use postgresql+psycopg2:// dialect for sync schema init - Fix connection pool leak in _lookup_element_mime() with try/finally - Narrow bare except to OperationalError/ProgrammingError on ALTER TABLE migration - Add expanduser() to OPTOPSY_DATA_DIR path resolution - Load .env before importing AUTH_SECRET_PATH in cli.py - Keep README.md in Docker build context (needed by pyproject.toml) - Pin uv Docker image to 0.10.6 instead of mutable :latest tag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add coverage for conninfo helpers, mime lookup, and paths resolution - TestGetAsyncConninfo: sqlite default, postgresql, legacy postgres, passthrough - TestGetSyncConninfo: sqlite default, psycopg2 dialect, asyncpg conversion, passthrough - TestLookupElementMime: mime found + dispose, no match + dispose, exception + dispose - TestResolveDataDir: default path, env override, tilde expansion Covers lines added/modified in the PR review fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c57763 commit 3020ac6

File tree

14 files changed

+598
-121
lines changed

14 files changed

+598
-121
lines changed

.dockerignore

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Version control
2+
.git
3+
.gitignore
4+
5+
# Python
6+
__pycache__
7+
*.pyc
8+
*.pyo
9+
.mypy_cache
10+
.ruff_cache
11+
12+
# Testing / dev
13+
tests/
14+
.pytest_cache
15+
.coverage
16+
htmlcov/
17+
18+
# CI / tooling
19+
.github/
20+
.pre-commit-config.yaml
21+
CLAUDE.md
22+
23+
# Environment
24+
.env
25+
.env.*
26+
!.env.example
27+
28+
# IDE
29+
.vscode/
30+
.idea/
31+
32+
# OS
33+
.DS_Store
34+
Thumbs.db

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,12 @@ ANTHROPIC_API_KEY=
1212

1313
# Data providers (add keys to enable live data fetching)
1414
EODHD_API_KEY=
15+
16+
# Database — set to use PostgreSQL instead of local SQLite.
17+
# When unset, defaults to sqlite at ~/.optopsy/chat.db.
18+
# Railway auto-exposes this from a linked Postgres service.
19+
# DATABASE_URL=postgresql://user:password@host:port/dbname
20+
21+
# Optional: override the base data directory (default: ~/.optopsy)
22+
# Useful for production deployments (e.g. Railway volume mount)
23+
# OPTOPSY_DATA_DIR=/data

Dockerfile

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# syntax=docker/dockerfile:1
2+
# Multi-stage build for Railway / container deployments.
3+
# Uses uv for fast, reproducible dependency resolution.
4+
5+
# ---------------------------------------------------------------------------
6+
# Stage 1: Build
7+
# ---------------------------------------------------------------------------
8+
FROM python:3.13-slim AS builder
9+
10+
COPY --from=ghcr.io/astral-sh/uv:0.10.6 /uv /usr/local/bin/uv
11+
12+
WORKDIR /app
13+
14+
# Install dependencies first (layer cache optimisation).
15+
COPY pyproject.toml uv.lock ./
16+
RUN uv sync --frozen --extra ui --no-dev --no-install-project
17+
18+
# Copy source and install the project itself.
19+
COPY . .
20+
RUN uv sync --frozen --extra ui --no-dev
21+
22+
# ---------------------------------------------------------------------------
23+
# Stage 2: Runtime
24+
# ---------------------------------------------------------------------------
25+
FROM python:3.13-slim
26+
27+
WORKDIR /app
28+
29+
# Copy the entire virtualenv + project from the builder.
30+
COPY --from=builder /app /app
31+
32+
ENV PATH="/app/.venv/bin:$PATH" \
33+
PYTHONUNBUFFERED=1
34+
35+
# Railway injects PORT; default to 8000 for local Docker runs.
36+
ENV PORT=8000
37+
38+
EXPOSE ${PORT}
39+
40+
CMD optopsy-chat run --host 0.0.0.0 --port ${PORT} --headless

optopsy/ui/app.py

Lines changed: 145 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
33
This module is the Chainlit entry point. It sets up:
44
5-
- **Database** — SQLite-backed persistence for chat threads, steps, and feedback
6-
(``_init_db_sync()``).
5+
- **Database** — Configurable persistence via ``DATABASE_URL`` (PostgreSQL) or
6+
SQLite fallback (``_init_db_sync()``).
77
- **Authentication** — Header-based auto-auth for single-user local use.
88
- **Session lifecycle** — ``on_chat_start`` creates a fresh ``OptopsyAgent``;
99
``on_chat_resume`` rebuilds message history from persisted steps.
@@ -47,8 +47,9 @@
4747

4848
import optopsy as op
4949
from optopsy.ui.agent import OptopsyAgent, _sanitize_tool_messages
50+
from optopsy.ui.paths import DB_PATH, STORAGE_DIR
5051
from optopsy.ui.providers import get_provider_names
51-
from optopsy.ui.storage import STORAGE_DIR, STORAGE_ROUTE_PREFIX, LocalStorageClient
52+
from optopsy.ui.storage import STORAGE_ROUTE_PREFIX, LocalStorageClient
5253

5354
# ---------------------------------------------------------------------------
5455
# Plotly binary-array patch
@@ -116,97 +117,142 @@ def _patched_plotly_post_init(self):
116117

117118
cl.Plotly.__post_init__ = _patched_plotly_post_init
118119

119-
DB_PATH = Path("~/.optopsy/chat.db").expanduser()
120-
121-
_DB_SCHEMA = """
122-
CREATE TABLE IF NOT EXISTS users (
123-
id TEXT PRIMARY KEY,
124-
identifier TEXT NOT NULL UNIQUE,
125-
"createdAt" TEXT NOT NULL,
126-
metadata TEXT DEFAULT '{}'
127-
);
128-
CREATE TABLE IF NOT EXISTS threads (
129-
id TEXT PRIMARY KEY,
130-
"userId" TEXT,
131-
"userIdentifier" TEXT,
132-
"createdAt" TEXT,
133-
name TEXT,
134-
metadata TEXT,
135-
tags TEXT,
136-
FOREIGN KEY("userId") REFERENCES users(id)
137-
);
138-
CREATE TABLE IF NOT EXISTS steps (
139-
id TEXT PRIMARY KEY,
140-
name TEXT,
141-
type TEXT,
142-
"threadId" TEXT NOT NULL,
143-
"parentId" TEXT,
144-
streaming INTEGER DEFAULT 0,
145-
"waitForAnswer" INTEGER,
146-
"isError" INTEGER,
147-
metadata TEXT DEFAULT '{}',
148-
tags TEXT,
149-
input TEXT,
150-
output TEXT,
151-
"createdAt" TEXT,
152-
start TEXT,
153-
"end" TEXT,
154-
generation TEXT DEFAULT '{}',
155-
"defaultOpen" INTEGER DEFAULT 0,
156-
"showInput" TEXT,
157-
language TEXT,
158-
FOREIGN KEY("threadId") REFERENCES threads(id)
159-
);
160-
CREATE TABLE IF NOT EXISTS feedbacks (
161-
id TEXT PRIMARY KEY,
162-
"forId" TEXT NOT NULL,
163-
value REAL,
164-
comment TEXT,
165-
FOREIGN KEY("forId") REFERENCES steps(id)
166-
);
167-
CREATE TABLE IF NOT EXISTS elements (
168-
id TEXT PRIMARY KEY,
169-
"threadId" TEXT NOT NULL,
170-
type TEXT,
171-
"chainlitKey" TEXT,
172-
url TEXT,
173-
"objectKey" TEXT,
174-
name TEXT,
175-
display TEXT,
176-
size TEXT,
177-
language TEXT,
178-
page TEXT,
179-
"forId" TEXT,
180-
mime TEXT,
181-
props TEXT DEFAULT '{}',
182-
"autoPlay" TEXT,
183-
"playerConfig" TEXT,
184-
FOREIGN KEY("threadId") REFERENCES threads(id)
185-
);
186-
"""
120+
_DB_SCHEMA_STATEMENTS = [
121+
"""CREATE TABLE IF NOT EXISTS users (
122+
id TEXT PRIMARY KEY,
123+
identifier TEXT NOT NULL UNIQUE,
124+
"createdAt" TEXT NOT NULL,
125+
metadata TEXT DEFAULT '{}'
126+
)""",
127+
"""CREATE TABLE IF NOT EXISTS threads (
128+
id TEXT PRIMARY KEY,
129+
"userId" TEXT,
130+
"userIdentifier" TEXT,
131+
"createdAt" TEXT,
132+
name TEXT,
133+
metadata TEXT,
134+
tags TEXT,
135+
FOREIGN KEY("userId") REFERENCES users(id)
136+
)""",
137+
"""CREATE TABLE IF NOT EXISTS steps (
138+
id TEXT PRIMARY KEY,
139+
name TEXT,
140+
type TEXT,
141+
"threadId" TEXT NOT NULL,
142+
"parentId" TEXT,
143+
streaming INTEGER DEFAULT 0,
144+
"waitForAnswer" INTEGER,
145+
"isError" INTEGER,
146+
metadata TEXT DEFAULT '{}',
147+
tags TEXT,
148+
input TEXT,
149+
output TEXT,
150+
"createdAt" TEXT,
151+
start TEXT,
152+
"end" TEXT,
153+
generation TEXT DEFAULT '{}',
154+
"defaultOpen" INTEGER DEFAULT 0,
155+
"showInput" TEXT,
156+
language TEXT,
157+
FOREIGN KEY("threadId") REFERENCES threads(id)
158+
)""",
159+
"""CREATE TABLE IF NOT EXISTS feedbacks (
160+
id TEXT PRIMARY KEY,
161+
"forId" TEXT NOT NULL,
162+
value REAL,
163+
comment TEXT,
164+
FOREIGN KEY("forId") REFERENCES steps(id)
165+
)""",
166+
"""CREATE TABLE IF NOT EXISTS elements (
167+
id TEXT PRIMARY KEY,
168+
"threadId" TEXT NOT NULL,
169+
type TEXT,
170+
"chainlitKey" TEXT,
171+
url TEXT,
172+
"objectKey" TEXT,
173+
name TEXT,
174+
display TEXT,
175+
size TEXT,
176+
language TEXT,
177+
page TEXT,
178+
"forId" TEXT,
179+
mime TEXT,
180+
props TEXT DEFAULT '{}',
181+
"autoPlay" TEXT,
182+
"playerConfig" TEXT,
183+
FOREIGN KEY("threadId") REFERENCES threads(id)
184+
)""",
185+
]
186+
187+
188+
def _get_async_conninfo() -> str:
189+
"""Return the async SQLAlchemy connection string.
190+
191+
When ``DATABASE_URL`` is set (e.g. ``postgresql://…``), it is converted to
192+
an asyncpg connection string. Otherwise, falls back to a local SQLite file.
193+
"""
194+
db_url = os.environ.get("DATABASE_URL", "")
195+
if db_url:
196+
# Railway/Heroku sometimes use ``postgres://`` (legacy) — normalise.
197+
if db_url.startswith("postgres://"):
198+
db_url = "postgresql://" + db_url[len("postgres://") :]
199+
# Convert to async driver variant.
200+
if db_url.startswith("postgresql://"):
201+
return "postgresql+asyncpg://" + db_url[len("postgresql://") :]
202+
return db_url
203+
return f"sqlite+aiosqlite:///{DB_PATH}"
204+
205+
206+
def _get_sync_conninfo() -> str:
207+
"""Return the synchronous SQLAlchemy connection string for schema init.
208+
209+
For PostgreSQL, uses the ``psycopg2`` driver (``postgresql+psycopg2://``).
210+
"""
211+
db_url = os.environ.get("DATABASE_URL", "")
212+
if db_url:
213+
if db_url.startswith("postgres://"):
214+
db_url = "postgresql://" + db_url[len("postgres://") :]
215+
if db_url.startswith("postgresql+asyncpg://"):
216+
db_url = "postgresql://" + db_url[len("postgresql+asyncpg://") :]
217+
if db_url.startswith("postgresql://"):
218+
return "postgresql+psycopg2://" + db_url[len("postgresql://") :]
219+
return db_url
220+
return f"sqlite:///{DB_PATH}"
187221

188222

189-
# Initialize database synchronously at module import time, before Chainlit
190-
# tries to use it via @cl.data_layer or authentication callbacks.
191223
def _init_db_sync() -> None:
192-
import sqlite3
224+
"""Create tables synchronously at module import time.
225+
226+
Uses SQLAlchemy so the same DDL works for both SQLite and PostgreSQL.
227+
The sync Postgres path requires ``psycopg2`` (included in the ``ui`` extra).
228+
"""
229+
from sqlalchemy import create_engine, text
230+
from sqlalchemy.exc import OperationalError, ProgrammingError
193231

194-
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
195-
conn = sqlite3.connect(DB_PATH)
232+
sync_url = _get_sync_conninfo()
233+
234+
# Ensure parent directory exists for SQLite.
235+
if sync_url.startswith("sqlite"):
236+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
237+
238+
engine = create_engine(sync_url)
196239
try:
197-
conn.executescript(_DB_SCHEMA)
198-
# Add columns introduced in newer Chainlit versions (safe to run repeatedly).
199-
for col, definition in [
200-
("defaultOpen", "INTEGER DEFAULT 0"),
201-
("waitForAnswer", "INTEGER"),
202-
]:
203-
try:
204-
conn.execute(f'ALTER TABLE steps ADD COLUMN "{col}" {definition}')
205-
except Exception:
206-
pass # column already exists
207-
conn.commit()
240+
with engine.begin() as conn:
241+
for stmt in _DB_SCHEMA_STATEMENTS:
242+
conn.execute(text(stmt))
243+
# Add columns introduced in newer Chainlit versions.
244+
for col, definition in [
245+
("defaultOpen", "INTEGER DEFAULT 0"),
246+
("waitForAnswer", "INTEGER"),
247+
]:
248+
try:
249+
conn.execute(
250+
text(f'ALTER TABLE steps ADD COLUMN "{col}" {definition}')
251+
)
252+
except (OperationalError, ProgrammingError):
253+
pass # column already exists
208254
finally:
209-
conn.close()
255+
engine.dispose()
210256

211257

212258
_init_db_sync()
@@ -216,19 +262,23 @@ def _init_db_sync() -> None:
216262

217263
async def _lookup_element_mime(object_key: str) -> str | None:
218264
"""Look up the stored mime type for an element by its object key."""
219-
import aiosqlite
265+
from sqlalchemy import text
266+
from sqlalchemy.ext.asyncio import create_async_engine
220267

268+
engine = create_async_engine(_get_async_conninfo())
221269
try:
222-
async with aiosqlite.connect(DB_PATH) as db:
223-
cursor = await db.execute(
224-
'SELECT mime FROM elements WHERE "objectKey" = ? LIMIT 1',
225-
(object_key,),
270+
async with engine.begin() as conn:
271+
result = await conn.execute(
272+
text('SELECT mime FROM elements WHERE "objectKey" = :key LIMIT 1'),
273+
{"key": object_key},
226274
)
227-
row = await cursor.fetchone()
275+
row = result.fetchone()
228276
if row and row[0] and row[0] != "application/octet-stream":
229277
return row[0]
230278
except Exception:
231279
pass
280+
finally:
281+
await engine.dispose()
232282
return None
233283

234284

@@ -286,7 +336,7 @@ async def _serve_storage_file(file_path: str):
286336
@cl.data_layer
287337
def get_data_layer() -> SQLAlchemyDataLayer:
288338
return SQLAlchemyDataLayer(
289-
conninfo=f"sqlite+aiosqlite:///{DB_PATH}",
339+
conninfo=_get_async_conninfo(),
290340
storage_provider=_storage_client,
291341
)
292342

optopsy/ui/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,17 @@ def _cmd_run(args: argparse.Namespace) -> None:
180180
ui_dir = os.path.dirname(os.path.abspath(__file__))
181181
os.environ.setdefault("CHAINLIT_APP_ROOT", ui_dir)
182182

183+
# Load .env before resolving paths so OPTOPSY_DATA_DIR is available.
184+
_load_env()
185+
183186
# Chainlit requires a JWT secret when auth callbacks are registered.
184187
# Generate one on first run and persist it so sessions survive restarts.
185188
if not os.environ.get("CHAINLIT_AUTH_SECRET"):
186189
import secrets
187-
from pathlib import Path
188190

189-
secret_file = Path("~/.optopsy/auth_secret").expanduser()
191+
from optopsy.ui.paths import AUTH_SECRET_PATH
192+
193+
secret_file = AUTH_SECRET_PATH
190194
secret_file.parent.mkdir(parents=True, exist_ok=True)
191195
if secret_file.exists():
192196
secret = secret_file.read_text().strip()

0 commit comments

Comments
 (0)