Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ def _normalize_sqlite_url(url: str) -> str:
)


def _is_sqlite() -> bool:
"""True when the configured database is SQLite.

A number of migration helpers below use SQLite-only introspection
(PRAGMA table_info, sqlite3.connect against the file path) to add
columns that may be missing on an old on-disk database. On non-SQLite
backends those columns are already present because the ORM's
Base.metadata.create_all() creates the full current schema up front,
so these migrations are meaningless there and must no-op instead of
running SQLite-only SQL against the configured engine.
"""
return engine.dialect.name == "sqlite"


# Sidecar files SQLite can create next to the main DB. -journal is the default
# rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
# secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
Expand Down Expand Up @@ -785,6 +799,8 @@ def _migrate_add_last_message_at_column():
backfill only touches rows where last_message_at is still NULL so it
won't clobber live values on later restarts."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -826,6 +842,8 @@ def _migrate_add_last_message_at_column():
def _migrate_add_document_archived_column():
"""Add `archived` to documents (soft-archive flag). Guarded + idempotent."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -850,6 +868,8 @@ def _migrate_add_document_archived_column():
def _migrate_add_owner_column():
"""Add owner column to sessions table if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -874,6 +894,8 @@ def _migrate_add_owner_column():
def _migrate_model_endpoints():
"""Recreate model_endpoints table if schema changed (url->base_url)."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -897,6 +919,8 @@ def _migrate_model_endpoints():
def _migrate_add_hidden_models_column():
"""Add hidden_models column to model_endpoints if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -927,6 +951,8 @@ def _migrate_add_model_endpoint_owner_column():
Backfills NULL for existing rows (treated as shared by the filter).
"""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -952,6 +978,8 @@ def _migrate_add_model_endpoint_owner_column():
def _migrate_add_provider_auth_id_column():
"""Add provider_auth_id column to model_endpoints if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -977,6 +1005,8 @@ def _migrate_add_provider_auth_id_column():
def _migrate_add_model_type_column():
"""Add model_type column to model_endpoints if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1000,6 +1030,8 @@ def _migrate_add_model_type_column():
def _migrate_add_model_endpoint_refresh_columns():
"""Add endpoint classification / refresh policy columns if missing."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -1028,6 +1060,8 @@ def _migrate_add_model_endpoint_refresh_columns():
def _migrate_add_task_run_model_column():
"""Add model column to task_runs if it doesn't exist (records which model ran)."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1051,6 +1085,8 @@ def _migrate_add_task_run_model_column():
def _migrate_add_supports_tools_column():
"""Add supports_tools column to model_endpoints if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1075,6 +1111,8 @@ def _migrate_add_supports_tools_column():
def _migrate_add_cached_models_column():
"""Add cached_models column to model_endpoints if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1097,6 +1135,8 @@ def _migrate_add_cached_models_column():
def _migrate_add_pinned_models_column():
"""Add pinned_models column to model_endpoints if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1120,6 +1160,8 @@ def _migrate_add_pinned_models_column():
def _migrate_add_notes_sort_order():
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -1152,6 +1194,8 @@ def _migrate_add_notes_sort_order():
def _migrate_add_mode_column():
"""Add mode column to sessions table if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1175,6 +1219,8 @@ def _migrate_add_mode_column():
def _migrate_add_folder_column():
"""Add folder column to sessions table if it doesn't exist."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1198,6 +1244,8 @@ def _migrate_add_folder_column():
def _migrate_add_token_columns():
"""Add cumulative token tracking columns to sessions table."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -1222,6 +1270,8 @@ def _migrate_add_token_columns():
def _migrate_add_owner_to_table(table_name: str, index_name: str):
"""Generic helper: add owner TEXT column + index to a table if missing."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -1258,6 +1308,8 @@ def _migrate_add_multiuser_owner_columns():
def _migrate_add_gallery_caption_column():
"""Add OCR/vision caption storage for gallery images."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -1286,6 +1338,8 @@ def _migrate_add_api_token_scopes_column():
as an unscoped bearer credential.
"""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -1341,6 +1395,8 @@ def _migrate_assign_legacy_owner():
if not admin_user:
return

if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -1420,6 +1476,8 @@ def _migrate_backfill_document_owner_from_session():
legacy-owner sweep, so session-linked docs get their *true* owner
while only genuinely orphaned (sessionless) docs fall through to the
admin assignment. Idempotent — only touches NULL-owner rows."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(documents)"))]
Expand All @@ -1442,6 +1500,8 @@ def _migrate_backfill_document_owner_from_session():

def _migrate_add_tidy_verdict():
"""Add tidy_verdict column to documents table if missing."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(documents)"))]
Expand All @@ -1461,6 +1521,8 @@ def _migrate_add_doc_source_email_cols():
"source_email_account_id": "VARCHAR",
"source_email_message_id": "VARCHAR",
}
if not _is_sqlite():
return
try:
with engine.connect() as conn:
existing = {r[1] for r in conn.execute(text("PRAGMA table_info(documents)"))}
Expand All @@ -1487,6 +1549,8 @@ def _migrate_add_task_automation_columns():
"trigger_count": "INTEGER",
"trigger_counter": "INTEGER DEFAULT 0",
}
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols_info = list(conn.execute(text("PRAGMA table_info(scheduled_tasks)")))
Expand Down Expand Up @@ -1552,6 +1616,8 @@ def _migrate_add_task_automation_columns():

def _migrate_add_email_oauth_columns():
"""Add Google OAuth and display_name columns to email_accounts if missing."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(email_accounts)"))]
Expand All @@ -1571,6 +1637,8 @@ def _migrate_add_email_oauth_columns():

def _migrate_add_oauth_config():
"""Add oauth_config column to mcp_servers table if missing."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(mcp_servers)"))]
Expand All @@ -1583,6 +1651,8 @@ def _migrate_add_oauth_config():

def _migrate_add_disabled_tools():
"""Add disabled_tools column to mcp_servers table if missing."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(mcp_servers)"))]
Expand All @@ -1600,6 +1670,8 @@ def _migrate_add_mcp_oauth_tokens_column():
TEXT on purpose: EncryptedText is a SQLAlchemy TypeDecorator that encrypts at
the Python layer and stores the ciphertext as TEXT, so the DB column type is
TEXT. This matches the existing encrypted columns (see _migrate_encrypt_*)."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(mcp_servers)"))]
Expand All @@ -1617,6 +1689,8 @@ def _migrate_add_task_v2_columns():
"then_task_id": "VARCHAR",
"webhook_token": "VARCHAR",
}
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(scheduled_tasks)"))]
Expand Down Expand Up @@ -1653,6 +1727,8 @@ def _migrate_drop_ping_notes_tasks():

def _migrate_add_notifications_enabled():
"""Per-task notification on/off toggle (default ON)."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(scheduled_tasks)"))]
Expand All @@ -1666,6 +1742,8 @@ def _migrate_add_notifications_enabled():

def _migrate_add_crew_member_id():
"""Add crew_member_id column to sessions and scheduled_tasks tables if missing."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(sessions)"))]
Expand All @@ -1683,6 +1761,8 @@ def _migrate_add_crew_member_id():

def _migrate_add_assistant_columns():
"""Add is_default_assistant + timezone columns to crew_members for the personal-assistant feature."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(crew_members)"))]
Expand Down Expand Up @@ -1816,6 +1896,8 @@ def _migrate_seed_email_account():
"""If email_accounts is empty and settings.json has legacy flat imap_host/smtp_host
keys, create a single default account from them so nothing breaks for users who
upgraded. Safe to run repeatedly — it short-circuits once any row exists."""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
tables = [r[0] for r in conn.execute(text(
Expand Down Expand Up @@ -1982,6 +2064,8 @@ def _migrate_backfill_task_folders():
older sessions that predate that assignment. Idempotent — only touches
rows where folder is NULL or empty and the title matches known prefixes.
"""
if not _is_sqlite():
return
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(sessions)"))]
Expand Down Expand Up @@ -2120,6 +2204,8 @@ def _scrub_legacy_chat_message_fts_media(conn) -> None:
def _migrate_add_email_smtp_security():
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -2243,6 +2329,8 @@ def _migrate_add_calendar_is_utc():
their original UTC timestamps (Z-suffix on the wire) without touching
legacy naive-local rows."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -2269,6 +2357,8 @@ def _migrate_add_calendar_origin():
rows (prunable when they vanish upstream) from locally-created ones (agent /
email triage / failed write-back), which must never be pruned. Idempotent."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -2295,6 +2385,8 @@ def _migrate_add_calendar_account_id():
"""Add `account_id` to calendars so each CalDAV-backed calendar knows which
credential set (from caldav_accounts in user prefs) owns it. Idempotent."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -2320,6 +2412,8 @@ def _migrate_add_calendar_account_id():
def _migrate_add_caldav_sync_columns():
"""Add remote CalDAV metadata used for bidirectional sync."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand All @@ -2345,6 +2439,8 @@ def _migrate_add_caldav_sync_columns():
def _migrate_add_calendar_metadata():
"""Add importance/event_type/last_pinged columns to calendar_events table."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down Expand Up @@ -2372,6 +2468,8 @@ def _migrate_add_calendar_metadata():
def _migrate_add_calendar_recurrence_exdates():
"""Add skipped recurrence occurrences for deleting one instance of a series."""
import sqlite3
if not _is_sqlite():
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
Expand Down