You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Replace the raw sqlite3 database layer with SQLAlchemy Core to support configurable database backends — starting with SQLite (default, zero-config) and PostgreSQL (production-grade, self-hosted or external).
Motivation
Production readiness: SQLite works for small deployments but has concurrency limitations under load (single-writer lock, no true connection pooling)
Future flexibility: SQLAlchemy Core supports SQLite, PostgreSQL, MySQL, MariaDB — adding new backends later is trivial
25 *Operations classes composed via DatabaseManager facade
SQLite-Specific Syntax Found
Pattern
Count
PG Equivalent
INTEGER PRIMARY KEY AUTOINCREMENT
6 tables
SERIAL PRIMARY KEY
? param placeholders
Hundreds
%s (psycopg2)
INSERT OR REPLACE
4 places
INSERT ... ON CONFLICT DO UPDATE
INSERT OR IGNORE
4 places
INSERT ... ON CONFLICT DO NOTHING
PRAGMA table_info()
30 migrations
information_schema.columns
sqlite3.IntegrityError
8 places
sqlalchemy.exc.IntegrityError
sqlite3.Row row factory
1 place
RowMapping
INTEGER for booleans
Many columns
Boolean
TEXT for timestamps
Many columns
DateTime
TEXT for JSON
~15 columns
JSON
Proposed Approach: SQLAlchemy Core (not ORM)
Use SQLAlchemy Core — the table/expression layer — without the ORM. This gives dialect-agnostic SQL generation while keeping the explicit query-building style the codebase already uses.
Why Core, Not ORM
Current code uses raw SQL with explicit row-to-dict converters — Core maps naturally to this pattern
No object identity map overhead
Operations classes stay as-is (they become query builders instead of SQL string builders)
DatabaseManager facade API doesn't change — callers are unaffected
Add database configuration section to deployment guide
Key Design Decisions
SQLAlchemy Core only — no ORM Session, no mapped classes, no relationship loading
Single DATABASE_URL env var — determines engine type at startup
SQLite remains default — zero-config for local dev and small deployments
PostgreSQL via Docker profile — docker compose --profile postgres up enables it
Upsert uses dialect-specific imports — from sqlalchemy.dialects.{sqlite,postgresql} import insert
Keep DatabaseManager facade — callers don't know or care which backend is active
Keep *Operations classes — they become SQLAlchemy query builders instead of SQL string builders
Alembic optional — can keep hand-rolled migrations using inspect() instead of PRAGMA
Scope & Risk
Component
Lines
Difficulty
db/tables.py (new)
~400
Easy
db/engine.py (new)
~60
Easy
db/connection.py
rewrite
Easy
db/schema.py
~800 rewrite
Medium
db/migrations.py
~1000 rewrite
Hard
29 db modules (queries)
~9500 audit
Medium
database.py (facade)
~50 changes
Easy
docker-compose.yml
~20 new
Easy
Risk mitigation: Phase 1 changes zero queries — just wraps existing SQLite access through SQLAlchemy. Each Phase 2 module can be merged independently. Rollback is straightforward at any point.
Acceptance Criteria
DATABASE_URL=sqlite:///data/trinity.db works identically to current behavior
DATABASE_URL=postgresql://... works with PostgreSQL 16
All 36 tables created correctly on both backends
Migrations run successfully on both backends
No raw sqlite3 imports remain in db/ modules
Docker Compose supports optional PostgreSQL via --profile postgres
Summary
Replace the raw
sqlite3database layer with SQLAlchemy Core to support configurable database backends — starting with SQLite (default, zero-config) and PostgreSQL (production-grade, self-hosted or external).Motivation
Current State
src/backend/db/PRAGMA-based introspection)db/connection.py(28 lines)?placeholders, no ORM*Operationsclasses composed viaDatabaseManagerfacadeSQLite-Specific Syntax Found
INTEGER PRIMARY KEY AUTOINCREMENTSERIAL PRIMARY KEY?param placeholders%s(psycopg2)INSERT OR REPLACEINSERT ... ON CONFLICT DO UPDATEINSERT OR IGNOREINSERT ... ON CONFLICT DO NOTHINGPRAGMA table_info()information_schema.columnssqlite3.IntegrityErrorsqlalchemy.exc.IntegrityErrorsqlite3.Rowrow factoryRowMappingINTEGERfor booleansBooleanTEXTfor timestampsDateTimeTEXTfor JSONJSONProposed Approach: SQLAlchemy Core (not ORM)
Use SQLAlchemy Core — the table/expression layer — without the ORM. This gives dialect-agnostic SQL generation while keeping the explicit query-building style the codebase already uses.
Why Core, Not ORM
DatabaseManagerfacade API doesn't change — callers are unaffectedConfiguration
Docker Compose Addition
Implementation Plan
Phase 1: Foundation (no behavior change)
sqlalchemy>=2.0to requirementsdb/tables.py— SQLAlchemyMetaData+Table()definitions for all 36 tablesdb/engine.py— engine factory fromDATABASE_URL, connection pooling configdb/connection.pycontext manager with SQLAlchemyengine.begin()wrapperPhase 2: Query Migration (module by module)
Convert each
*Operationsclass from raw SQL strings to SQLAlchemy Core expressions. Each module is independent, so this can be done incrementally:db/users.py— simple CRUD, good pilot moduledb/settings.py— has upsert pattern (INSERT OR REPLACE)db/tags.py— hasINSERT OR IGNOREpatterndb/permissions.py— batch upsert + delete-and-replacedb/mcp_keys.py— CRUD + hash lookupdb/email_auth.py— time-limited codesdb/activities.py— fix positional row access (row[0]) to named accessdb/chat.py— multi-table transactions (message + session update)db/agents.py+db/agent_settings/— mixin composition, JOINsdb/schedules.py— largest module (~53K), complex aggregationsdb/shared_folders.py,db/notifications.py,db/subscriptions.pydb/public_links.py,db/public_chat.pydb/slack.py,db/slack_channels.py,db/telegram_channels.pydb/monitoring.py,db/dashboard_history.pydb/operator_queue.py,db/event_subscriptions.pydb/nevermined.py,db/system_views.py,db/skills.pyPhase 3: Schema & Migrations
db/schema.pyDDL strings withmetadata.create_all(engine)(auto-generates dialect-appropriate DDL)db/migrations.pyfromPRAGMA-based introspection to Alembic or SQLAlchemy-based introspection (inspect(engine).get_columns())Phase 4: PostgreSQL Integration
docker-compose.yml(withprofiles: ["postgres"])pool_size,max_overflow)Phase 5: Cleanup
sqlite3imports from all db modulesCLAUDE.mdandarchitecture.mdKey Design Decisions
DATABASE_URLenv var — determines engine type at startupdocker compose --profile postgres upenables itfrom sqlalchemy.dialects.{sqlite,postgresql} import insertDatabaseManagerfacade — callers don't know or care which backend is active*Operationsclasses — they become SQLAlchemy query builders instead of SQL string buildersinspect()instead ofPRAGMAScope & Risk
db/tables.py(new)db/engine.py(new)db/connection.pydb/schema.pydb/migrations.pydatabase.py(facade)docker-compose.ymlRisk mitigation: Phase 1 changes zero queries — just wraps existing SQLite access through SQLAlchemy. Each Phase 2 module can be merged independently. Rollback is straightforward at any point.
Acceptance Criteria
DATABASE_URL=sqlite:///data/trinity.dbworks identically to current behaviorDATABASE_URL=postgresql://...works with PostgreSQL 16sqlite3imports remain indb/modules--profile postgres