Skip to content

Commit 0640b5b

Browse files
committed
Replace raw SQL schema with SQLAlchemy Table definitions
Move the Chainlit persistence DDL from hand-written SQL strings into proper SQLAlchemy Table/Column definitions (optopsy/ui/models.py). metadata.create_all() now emits dialect-appropriate DDL automatically: - PostgreSQL: UUID, JSONB, TEXT[], BOOLEAN, ON DELETE CASCADE - SQLite: TEXT, TEXT, TEXT, BOOLEAN (INTEGER affinity), ON DELETE CASCADE Also aligns with the Chainlit docs schema: - Add missing columns: steps.command, steps.indent, feedbacks.threadId - Fix types: feedbacks.value INT (was REAL), elements.page INT (was TEXT) - Fix nullability: steps.name/type NOT NULL, elements.name NOT NULL - Add ON DELETE CASCADE to all foreign keys - Remove stale columns: elements.autoPlay, elements.playerConfig https://claude.ai/code/session_01M8Tb8gCPXhR6VsExM2KNCm
1 parent d15b852 commit 0640b5b

File tree

2 files changed

+123
-103
lines changed

2 files changed

+123
-103
lines changed

optopsy/ui/app.py

Lines changed: 7 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -136,72 +136,7 @@ def _patched_plotly_post_init(self):
136136

137137
cl.Plotly.__post_init__ = _patched_plotly_post_init
138138

139-
_DB_SCHEMA_STATEMENTS = [
140-
"""CREATE TABLE IF NOT EXISTS users (
141-
id TEXT PRIMARY KEY,
142-
identifier TEXT NOT NULL UNIQUE,
143-
"createdAt" TEXT NOT NULL,
144-
metadata TEXT DEFAULT '{}'
145-
)""",
146-
"""CREATE TABLE IF NOT EXISTS threads (
147-
id TEXT PRIMARY KEY,
148-
"userId" TEXT,
149-
"userIdentifier" TEXT,
150-
"createdAt" TEXT,
151-
name TEXT,
152-
metadata TEXT,
153-
tags TEXT,
154-
FOREIGN KEY("userId") REFERENCES users(id)
155-
)""",
156-
"""CREATE TABLE IF NOT EXISTS steps (
157-
id TEXT PRIMARY KEY,
158-
name TEXT,
159-
type TEXT,
160-
"threadId" TEXT NOT NULL,
161-
"parentId" TEXT,
162-
streaming INTEGER DEFAULT 0,
163-
"waitForAnswer" INTEGER,
164-
"isError" INTEGER,
165-
metadata TEXT DEFAULT '{}',
166-
tags TEXT,
167-
input TEXT,
168-
output TEXT,
169-
"createdAt" TEXT,
170-
start TEXT,
171-
"end" TEXT,
172-
generation TEXT DEFAULT '{}',
173-
"defaultOpen" INTEGER DEFAULT 0,
174-
"showInput" TEXT,
175-
language TEXT,
176-
FOREIGN KEY("threadId") REFERENCES threads(id)
177-
)""",
178-
"""CREATE TABLE IF NOT EXISTS feedbacks (
179-
id TEXT PRIMARY KEY,
180-
"forId" TEXT NOT NULL,
181-
value REAL,
182-
comment TEXT,
183-
FOREIGN KEY("forId") REFERENCES steps(id)
184-
)""",
185-
"""CREATE TABLE IF NOT EXISTS elements (
186-
id TEXT PRIMARY KEY,
187-
"threadId" TEXT NOT NULL,
188-
type TEXT,
189-
"chainlitKey" TEXT,
190-
url TEXT,
191-
"objectKey" TEXT,
192-
name TEXT,
193-
display TEXT,
194-
size TEXT,
195-
language TEXT,
196-
page TEXT,
197-
"forId" TEXT,
198-
mime TEXT,
199-
props TEXT DEFAULT '{}',
200-
"autoPlay" TEXT,
201-
"playerConfig" TEXT,
202-
FOREIGN KEY("threadId") REFERENCES threads(id)
203-
)""",
204-
]
139+
from optopsy.ui.models import metadata as _db_metadata
205140

206141

207142
def _get_async_conninfo() -> str:
@@ -242,17 +177,18 @@ def _get_sync_conninfo() -> str:
242177
def _init_db_sync() -> None:
243178
"""Create tables synchronously at module import time.
244179
245-
Uses SQLAlchemy so the same DDL works for both SQLite and PostgreSQL.
246-
The sync Postgres path requires ``psycopg2`` (included in the ``ui`` extra).
180+
Uses SQLAlchemy's ``metadata.create_all()`` so the DDL is generated from
181+
the Table definitions in ``models.py`` — emitting native ``UUID``,
182+
``JSONB``, ``TEXT[]``, and ``BOOLEAN`` on PostgreSQL while falling back
183+
to ``TEXT`` / ``INTEGER`` on SQLite.
247184
248185
Retries up to 5 times with exponential backoff so the app survives
249186
transient database unavailability (e.g. Railway starting the DB service
250187
concurrently with the app).
251188
"""
252189
import time
253190

254-
from sqlalchemy import create_engine, text
255-
from sqlalchemy.exc import OperationalError, ProgrammingError
191+
from sqlalchemy import create_engine
256192

257193
sync_url = _get_sync_conninfo()
258194

@@ -265,39 +201,7 @@ def _init_db_sync() -> None:
265201
for attempt in range(max_retries):
266202
engine = create_engine(sync_url)
267203
try:
268-
with engine.begin() as conn:
269-
for stmt in _DB_SCHEMA_STATEMENTS:
270-
conn.execute(text(stmt))
271-
272-
# Add columns introduced in newer Chainlit versions.
273-
# Each ALTER TABLE runs in its own transaction so that a
274-
# "column already exists" error on PostgreSQL doesn't abort
275-
# subsequent statements (PostgreSQL marks the whole
276-
# transaction as failed after any error). Using separate
277-
# transactions avoids SAVEPOINTs, which are unreliable with
278-
# pysqlite's default transaction handling.
279-
is_pg = sync_url.startswith("postgresql")
280-
for col, definition in [
281-
("defaultOpen", "INTEGER DEFAULT 0"),
282-
("waitForAnswer", "INTEGER"),
283-
]:
284-
try:
285-
with engine.begin() as conn:
286-
if is_pg:
287-
conn.execute(
288-
text(
289-
f'ALTER TABLE steps ADD COLUMN IF NOT EXISTS "{col}" {definition}'
290-
)
291-
)
292-
else:
293-
conn.execute(
294-
text(
295-
f'ALTER TABLE steps ADD COLUMN "{col}" {definition}'
296-
)
297-
)
298-
except (OperationalError, ProgrammingError):
299-
pass # column already exists
300-
204+
_db_metadata.create_all(engine)
301205
return # success
302206
except Exception:
303207
engine.dispose()

optopsy/ui/models.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""SQLAlchemy table definitions for the Chainlit persistence layer.
2+
3+
Defines a single schema that emits dialect-appropriate DDL for both SQLite
4+
and PostgreSQL via ``metadata.create_all(engine)``.
5+
6+
On PostgreSQL, columns use native ``UUID``, ``JSONB``, ``TEXT[]``, and
7+
``BOOLEAN`` types. On SQLite the same definitions fall back to ``TEXT``
8+
and ``INTEGER`` — no hand-written SQL required.
9+
"""
10+
11+
from sqlalchemy import (
12+
Boolean,
13+
Column,
14+
ForeignKey,
15+
Integer,
16+
MetaData,
17+
Table,
18+
Text,
19+
)
20+
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY
21+
from sqlalchemy.dialects.postgresql import JSONB
22+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
23+
24+
metadata = MetaData()
25+
26+
# ---------------------------------------------------------------------------
27+
# Dialect-aware column types
28+
#
29+
# On PostgreSQL these resolve to native UUID / JSONB / TEXT[].
30+
# On SQLite they fall back to plain TEXT so Chainlit's raw-SQL data layer
31+
# (which serialises everything to strings) keeps working unchanged.
32+
# ---------------------------------------------------------------------------
33+
_UUID = Text().with_variant(PG_UUID(as_uuid=False), "postgresql")
34+
_JSON = Text().with_variant(JSONB(), "postgresql")
35+
_TAGS = Text().with_variant(PG_ARRAY(Text), "postgresql")
36+
37+
users = Table(
38+
"users",
39+
metadata,
40+
Column("id", _UUID, primary_key=True),
41+
Column("identifier", Text, nullable=False, unique=True),
42+
Column("metadata", _JSON, nullable=False),
43+
Column("createdAt", Text),
44+
)
45+
46+
threads = Table(
47+
"threads",
48+
metadata,
49+
Column("id", _UUID, primary_key=True),
50+
Column("createdAt", Text),
51+
Column("name", Text),
52+
Column("userId", _UUID, ForeignKey("users.id", ondelete="CASCADE")),
53+
Column("userIdentifier", Text),
54+
Column("tags", _TAGS),
55+
Column("metadata", _JSON),
56+
)
57+
58+
steps = Table(
59+
"steps",
60+
metadata,
61+
Column("id", _UUID, primary_key=True),
62+
Column("name", Text, nullable=False),
63+
Column("type", Text, nullable=False),
64+
Column(
65+
"threadId",
66+
_UUID,
67+
ForeignKey("threads.id", ondelete="CASCADE"),
68+
nullable=False,
69+
),
70+
Column("parentId", _UUID),
71+
Column("streaming", Boolean, nullable=False),
72+
Column("waitForAnswer", Boolean),
73+
Column("isError", Boolean),
74+
Column("metadata", _JSON),
75+
Column("tags", _TAGS),
76+
Column("input", Text),
77+
Column("output", Text),
78+
Column("createdAt", Text),
79+
Column("command", Text),
80+
Column("start", Text),
81+
Column("end", Text),
82+
Column("generation", _JSON),
83+
Column("showInput", Text),
84+
Column("language", Text),
85+
Column("indent", Integer),
86+
Column("defaultOpen", Boolean),
87+
)
88+
89+
elements = Table(
90+
"elements",
91+
metadata,
92+
Column("id", _UUID, primary_key=True),
93+
Column("threadId", _UUID, ForeignKey("threads.id", ondelete="CASCADE")),
94+
Column("type", Text),
95+
Column("url", Text),
96+
Column("chainlitKey", Text),
97+
Column("name", Text, nullable=False),
98+
Column("display", Text),
99+
Column("objectKey", Text),
100+
Column("size", Text),
101+
Column("page", Integer),
102+
Column("language", Text),
103+
Column("forId", _UUID),
104+
Column("mime", Text),
105+
Column("props", _JSON),
106+
)
107+
108+
feedbacks = Table(
109+
"feedbacks",
110+
metadata,
111+
Column("id", _UUID, primary_key=True),
112+
Column("forId", _UUID, nullable=False),
113+
Column("threadId", _UUID, ForeignKey("threads.id", ondelete="CASCADE")),
114+
Column("value", Integer, nullable=False),
115+
Column("comment", Text),
116+
)

0 commit comments

Comments
 (0)