From 94abdca2f781a2f2dbdfeebfe4ce5da20461c510 Mon Sep 17 00:00:00 2001 From: latte Date: Mon, 12 Jan 2026 18:30:55 +0100 Subject: [PATCH] Replace Alembic with plain SQL schema - Fix scalar_first() bug in persistent_conversation.py (use scalars().first()) - Add schema.sql with all 7 tables (users, user_preferences, user_facts, guilds, guild_members, conversations, messages) - Update database.py to run schema.sql on startup - Remove Alembic directory and configuration - Remove alembic from requirements.txt --- alembic.ini | 71 ------- alembic/env.py | 86 -------- alembic/script.py.mako | 26 --- .../versions/20250112_0001_initial_schema.py | 200 ------------------ requirements.txt | 1 - schema.sql | 119 +++++++++++ src/daemon_boyfriend/services/database.py | 23 +- .../services/persistent_conversation.py | 2 +- 8 files changed, 139 insertions(+), 389 deletions(-) delete mode 100644 alembic.ini delete mode 100644 alembic/env.py delete mode 100644 alembic/script.py.mako delete mode 100644 alembic/versions/20250112_0001_initial_schema.py create mode 100644 schema.sql diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 10c13c7..0000000 --- a/alembic.ini +++ /dev/null @@ -1,71 +0,0 @@ -# Alembic Configuration File - -[alembic] -# path to migration scripts -script_location = alembic - -# template used to generate migration file names -file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s - -# sys.path path prepend for the migration environment -prepend_sys_path = src - -# timezone to use when rendering the date within the migration file -timezone = UTC - -# max length of characters to apply to the "slug" field -truncate_slug_length = 40 - -# set to 'true' to run the environment during the 'revision' command -revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without a source .py file -sourceless = false - -# version number format -version_num_width = 4 - -# version path separator -version_path_separator = os - -# output encoding used when revision files are written -output_encoding = utf-8 - -# Database URL - will be overridden by env.py from settings -sqlalchemy.url = driver://user:pass@localhost/dbname - -[post_write_hooks] - -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index b85ee68..0000000 --- a/alembic/env.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Alembic migration environment configuration.""" - -import asyncio -from logging.config import fileConfig - -from sqlalchemy import pool -from sqlalchemy.ext.asyncio import async_engine_from_config - -from alembic import context -from daemon_boyfriend.config import settings -from daemon_boyfriend.models import Base - -# this is the Alembic Config object -config = context.config - -# Interpret the config file for Python logging -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# Model metadata for autogenerate support -target_metadata = Base.metadata - - -def get_url() -> str: - """Get database URL from settings.""" - url = settings.database_url - if not url: - raise ValueError("DATABASE_URL must be set for migrations") - return url - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL and not an Engine, - though an Engine is acceptable here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the script output. - """ - url = get_url() - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def do_run_migrations(connection) -> None: - """Run migrations with the given connection.""" - context.configure(connection=connection, target_metadata=target_metadata) - - with context.begin_transaction(): - context.run_migrations() - - -async def run_async_migrations() -> None: - """Run migrations in async mode.""" - configuration = config.get_section(config.config_ini_section, {}) - configuration["sqlalchemy.url"] = get_url() - - connectable = async_engine_from_config( - configuration, - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - await connectable.dispose() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" - asyncio.run(run_async_migrations()) - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index fbc4b07..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -1,26 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/20250112_0001_initial_schema.py b/alembic/versions/20250112_0001_initial_schema.py deleted file mode 100644 index 2c9998d..0000000 --- a/alembic/versions/20250112_0001_initial_schema.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Initial schema with users, conversations, messages, and guilds. - -Revision ID: 0001 -Revises: -Create Date: 2025-01-12 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "0001" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Users table - op.create_table( - "users", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("discord_id", sa.BigInteger(), nullable=False), - sa.Column("discord_username", sa.String(255), nullable=False), - sa.Column("discord_display_name", sa.String(255), nullable=True), - sa.Column("custom_name", sa.String(255), nullable=True), - sa.Column("first_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), - sa.UniqueConstraint("discord_id", name=op.f("uq_users_discord_id")), - ) - op.create_index(op.f("ix_users_discord_id"), "users", ["discord_id"]) - op.create_index(op.f("ix_users_last_seen_at"), "users", ["last_seen_at"]) - - # User preferences table - op.create_table( - "user_preferences", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.BigInteger(), nullable=False), - sa.Column("preference_key", sa.String(100), nullable=False), - sa.Column("preference_value", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - name=op.f("fk_user_preferences_user_id_users"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_user_preferences")), - sa.UniqueConstraint("user_id", "preference_key", name="uq_user_preferences_user_key"), - ) - op.create_index(op.f("ix_user_preferences_user_id"), "user_preferences", ["user_id"]) - - # User facts table - op.create_table( - "user_facts", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.BigInteger(), nullable=False), - sa.Column("fact_type", sa.String(50), nullable=False), - sa.Column("fact_content", sa.Text(), nullable=False), - sa.Column("confidence", sa.Float(), server_default="1.0"), - sa.Column("source", sa.String(50), server_default="conversation"), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), - sa.Column("learned_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("last_referenced_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - name=op.f("fk_user_facts_user_id_users"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_user_facts")), - ) - op.create_index(op.f("ix_user_facts_user_id"), "user_facts", ["user_id"]) - op.create_index(op.f("ix_user_facts_fact_type"), "user_facts", ["fact_type"]) - op.create_index(op.f("ix_user_facts_is_active"), "user_facts", ["is_active"]) - - # Guilds table - op.create_table( - "guilds", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("discord_id", sa.BigInteger(), nullable=False), - sa.Column("name", sa.String(255), nullable=False), - sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), - sa.Column("settings", postgresql.JSONB(), server_default="{}"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.PrimaryKeyConstraint("id", name=op.f("pk_guilds")), - sa.UniqueConstraint("discord_id", name=op.f("uq_guilds_discord_id")), - ) - op.create_index(op.f("ix_guilds_discord_id"), "guilds", ["discord_id"]) - - # Guild members table - op.create_table( - "guild_members", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("guild_id", sa.BigInteger(), nullable=False), - sa.Column("user_id", sa.BigInteger(), nullable=False), - sa.Column("guild_nickname", sa.String(255), nullable=True), - sa.Column("roles", postgresql.ARRAY(sa.Text()), nullable=True), - sa.Column("joined_guild_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.ForeignKeyConstraint( - ["guild_id"], - ["guilds.id"], - name=op.f("fk_guild_members_guild_id_guilds"), - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - name=op.f("fk_guild_members_user_id_users"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_guild_members")), - sa.UniqueConstraint("guild_id", "user_id", name="uq_guild_members_guild_user"), - ) - op.create_index(op.f("ix_guild_members_guild_id"), "guild_members", ["guild_id"]) - op.create_index(op.f("ix_guild_members_user_id"), "guild_members", ["user_id"]) - - # Conversations table - op.create_table( - "conversations", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.BigInteger(), nullable=False), - sa.Column("guild_id", sa.BigInteger(), nullable=True), - sa.Column("channel_id", sa.BigInteger(), nullable=True), - sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("last_message_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("message_count", sa.Integer(), server_default="0"), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - name=op.f("fk_conversations_user_id_users"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_conversations")), - ) - op.create_index(op.f("ix_conversations_user_id"), "conversations", ["user_id"]) - op.create_index(op.f("ix_conversations_channel_id"), "conversations", ["channel_id"]) - op.create_index(op.f("ix_conversations_last_message_at"), "conversations", ["last_message_at"]) - op.create_index(op.f("ix_conversations_is_active"), "conversations", ["is_active"]) - - # Messages table - op.create_table( - "messages", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("conversation_id", sa.BigInteger(), nullable=False), - sa.Column("user_id", sa.BigInteger(), nullable=False), - sa.Column("discord_message_id", sa.BigInteger(), nullable=True), - sa.Column("role", sa.String(20), nullable=False), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("has_images", sa.Boolean(), nullable=False, server_default="false"), - sa.Column("image_urls", postgresql.ARRAY(sa.Text()), nullable=True), - sa.Column("token_count", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.ForeignKeyConstraint( - ["conversation_id"], - ["conversations.id"], - name=op.f("fk_messages_conversation_id_conversations"), - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - name=op.f("fk_messages_user_id_users"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_messages")), - ) - op.create_index(op.f("ix_messages_conversation_id"), "messages", ["conversation_id"]) - op.create_index(op.f("ix_messages_user_id"), "messages", ["user_id"]) - op.create_index(op.f("ix_messages_created_at"), "messages", ["created_at"]) - - -def downgrade() -> None: - op.drop_table("messages") - op.drop_table("conversations") - op.drop_table("guild_members") - op.drop_table("guilds") - op.drop_table("user_facts") - op.drop_table("user_preferences") - op.drop_table("users") diff --git a/requirements.txt b/requirements.txt index a2ec44e..07812f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,3 @@ python-dotenv>=1.0.0 # Database asyncpg>=0.29.0 sqlalchemy[asyncio]>=2.0.0 -alembic>=1.13.0 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..005de7c --- /dev/null +++ b/schema.sql @@ -0,0 +1,119 @@ +-- Daemon Boyfriend Database Schema +-- Run with: psql -U postgres -d daemon_boyfriend -f schema.sql + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + discord_id BIGINT NOT NULL UNIQUE, + discord_username VARCHAR(255) NOT NULL, + discord_display_name VARCHAR(255), + custom_name VARCHAR(255), + first_seen_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ix_users_discord_id ON users(discord_id); +CREATE INDEX IF NOT EXISTS ix_users_last_seen_at ON users(last_seen_at); + +-- User preferences table +CREATE TABLE IF NOT EXISTS user_preferences ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + preference_key VARCHAR(100) NOT NULL, + preference_value TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, preference_key) +); + +CREATE INDEX IF NOT EXISTS ix_user_preferences_user_id ON user_preferences(user_id); + +-- User facts table +CREATE TABLE IF NOT EXISTS user_facts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + fact_type VARCHAR(50) NOT NULL, + fact_content TEXT NOT NULL, + confidence FLOAT DEFAULT 1.0, + source VARCHAR(50) DEFAULT 'conversation', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + learned_at TIMESTAMPTZ DEFAULT NOW(), + last_referenced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ix_user_facts_user_id ON user_facts(user_id); +CREATE INDEX IF NOT EXISTS ix_user_facts_fact_type ON user_facts(fact_type); +CREATE INDEX IF NOT EXISTS ix_user_facts_is_active ON user_facts(is_active); + +-- Guilds table +CREATE TABLE IF NOT EXISTS guilds ( + id BIGSERIAL PRIMARY KEY, + discord_id BIGINT NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + joined_at TIMESTAMPTZ DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + settings JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ix_guilds_discord_id ON guilds(discord_id); + +-- Guild members table +CREATE TABLE IF NOT EXISTS guild_members ( + id BIGSERIAL PRIMARY KEY, + guild_id BIGINT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + guild_nickname VARCHAR(255), + roles TEXT[], + joined_guild_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(guild_id, user_id) +); + +CREATE INDEX IF NOT EXISTS ix_guild_members_guild_id ON guild_members(guild_id); +CREATE INDEX IF NOT EXISTS ix_guild_members_user_id ON guild_members(user_id); + +-- Conversations table +CREATE TABLE IF NOT EXISTS conversations ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + guild_id BIGINT, + channel_id BIGINT, + started_at TIMESTAMPTZ DEFAULT NOW(), + last_message_at TIMESTAMPTZ DEFAULT NOW(), + message_count INTEGER DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ix_conversations_user_id ON conversations(user_id); +CREATE INDEX IF NOT EXISTS ix_conversations_channel_id ON conversations(channel_id); +CREATE INDEX IF NOT EXISTS ix_conversations_last_message_at ON conversations(last_message_at); +CREATE INDEX IF NOT EXISTS ix_conversations_is_active ON conversations(is_active); + +-- Messages table +CREATE TABLE IF NOT EXISTS messages ( + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + discord_message_id BIGINT, + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + has_images BOOLEAN NOT NULL DEFAULT FALSE, + image_urls TEXT[], + token_count INTEGER, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ix_messages_conversation_id ON messages(conversation_id); +CREATE INDEX IF NOT EXISTS ix_messages_user_id ON messages(user_id); +CREATE INDEX IF NOT EXISTS ix_messages_created_at ON messages(created_at); diff --git a/src/daemon_boyfriend/services/database.py b/src/daemon_boyfriend/services/database.py index 28a417f..8d78b6c 100644 --- a/src/daemon_boyfriend/services/database.py +++ b/src/daemon_boyfriend/services/database.py @@ -2,15 +2,19 @@ import logging from contextlib import asynccontextmanager +from pathlib import Path from typing import AsyncGenerator +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from daemon_boyfriend.config import settings -from daemon_boyfriend.models.base import Base logger = logging.getLogger(__name__) +# Path to schema.sql (project root) +SCHEMA_PATH = Path(__file__).parent.parent.parent.parent / "schema.sql" + class DatabaseService: """Manages database connections and sessions.""" @@ -62,13 +66,24 @@ class DatabaseService: logger.info("Database connection closed") async def create_tables(self) -> None: - """Create all tables (for development/testing).""" + """Create all tables from schema.sql.""" if not self._engine: raise RuntimeError("Database not initialized") + if not SCHEMA_PATH.exists(): + logger.warning(f"schema.sql not found at {SCHEMA_PATH}, skipping table creation") + return + + schema_sql = SCHEMA_PATH.read_text() + async with self._engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - logger.info("Database tables created") + # Execute each statement separately (PostgreSQL doesn't like multiple in one) + for statement in schema_sql.split(";"): + statement = statement.strip() + if statement and not statement.startswith("--"): + await conn.execute(text(statement)) + + logger.info("Database tables created from schema.sql") @asynccontextmanager async def session(self) -> AsyncGenerator[AsyncSession, None]: diff --git a/src/daemon_boyfriend/services/persistent_conversation.py b/src/daemon_boyfriend/services/persistent_conversation.py index 9ee6258..3c46d15 100644 --- a/src/daemon_boyfriend/services/persistent_conversation.py +++ b/src/daemon_boyfriend/services/persistent_conversation.py @@ -52,7 +52,7 @@ class PersistentConversationManager: stmt = stmt.order_by(Conversation.last_message_at.desc()) result = await self._session.execute(stmt) - conversation = result.scalar_first() + conversation = result.scalars().first() if conversation: logger.debug(