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:
71
alembic.ini
71
alembic.ini
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
119
schema.sql
Normal 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);
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user