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
This commit is contained in:
2026-01-12 18:30:55 +01:00
parent 707410e2ce
commit 94abdca2f7
8 changed files with 139 additions and 389 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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")

View File

@@ -17,4 +17,3 @@ python-dotenv>=1.0.0
# Database # Database
asyncpg>=0.29.0 asyncpg>=0.29.0
sqlalchemy[asyncio]>=2.0.0 sqlalchemy[asyncio]>=2.0.0
alembic>=1.13.0

119
schema.sql Normal file
View File

@@ -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);

View File

@@ -2,15 +2,19 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from daemon_boyfriend.config import settings from daemon_boyfriend.config import settings
from daemon_boyfriend.models.base import Base
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Path to schema.sql (project root)
SCHEMA_PATH = Path(__file__).parent.parent.parent.parent / "schema.sql"
class DatabaseService: class DatabaseService:
"""Manages database connections and sessions.""" """Manages database connections and sessions."""
@@ -62,13 +66,24 @@ class DatabaseService:
logger.info("Database connection closed") logger.info("Database connection closed")
async def create_tables(self) -> None: async def create_tables(self) -> None:
"""Create all tables (for development/testing).""" """Create all tables from schema.sql."""
if not self._engine: if not self._engine:
raise RuntimeError("Database not initialized") 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: async with self._engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) # Execute each statement separately (PostgreSQL doesn't like multiple in one)
logger.info("Database tables created") 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 @asynccontextmanager
async def session(self) -> AsyncGenerator[AsyncSession, None]: async def session(self) -> AsyncGenerator[AsyncSession, None]:

View File

@@ -52,7 +52,7 @@ class PersistentConversationManager:
stmt = stmt.order_by(Conversation.last_message_at.desc()) stmt = stmt.order_by(Conversation.last_message_at.desc())
result = await self._session.execute(stmt) result = await self._session.execute(stmt)
conversation = result.scalar_first() conversation = result.scalars().first()
if conversation: if conversation:
logger.debug( logger.debug(