feat: split initial social schema migration chain

replace monolithic migration with ordered scripts, include profiles/sessions in migration, and verify full downgrade/upgrade cycle for clean Supabase bootstrap
This commit is contained in:
qzl
2026-02-26 17:58:37 +08:00
parent 2994cc708c
commit 6641eba9df
22 changed files with 2242 additions and 23 deletions
+10
View File
@@ -20,9 +20,19 @@ from core.db.base import Base # noqa: E402
from models import ( # noqa: F401,E402 from models import ( # noqa: F401,E402
AgentChatMessage, AgentChatMessage,
AgentChatSession, AgentChatSession,
AutomationJob,
Group,
GroupMember,
InboxMessage,
Llm, Llm,
LlmFactory, LlmFactory,
Memory,
Profile, Profile,
ScheduleItem,
ScheduleSubscription,
Todo,
TodoSource,
UserAgent,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -0,0 +1,285 @@
"""initial schema part 1: foundation tables
Revision ID: 202602260001
Revises:
Create Date: 2026-02-26 20:10:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "202602260001"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"llm_factory",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("request_url", sa.String(length=255), nullable=False),
sa.Column("avatar", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True)
_enable_rls("llm_factory")
op.create_table(
"llms",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("factory_id", sa.UUID(), nullable=False),
sa.Column("model_code", sa.String(length=50), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("model_code"),
)
op.create_index("ix_llms_factory_id", "llms", ["factory_id"], unique=False)
op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True)
op.create_foreign_key(
"fk_llms_factory_id",
"llms",
"llm_factory",
["factory_id"],
["id"],
ondelete="RESTRICT",
)
_enable_rls("llms")
op.create_table(
"profiles",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("username", sa.String(length=30), nullable=False),
sa.Column("avatar_url", sa.Text(), nullable=True),
sa.Column("bio", sa.String(length=200), nullable=True),
sa.Column(
"settings",
postgresql.JSONB(astext_type=sa.Text()),
server_default="{}",
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_profiles_username", "profiles", ["username"], unique=False)
op.create_index(
"ix_profiles_settings_gin",
"profiles",
["settings"],
unique=False,
postgresql_using="gin",
)
op.create_foreign_key(
"fk_profiles_id",
"profiles",
"users",
["id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
_enable_rls("profiles")
op.execute(
"""
CREATE OR REPLACE FUNCTION public.create_profile_for_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
VALUES (
NEW.id,
COALESCE(
NEW.raw_user_meta_data ->> 'username',
split_part(NEW.email, '@', 1),
'user_' || substring(NEW.id::text, 1, 8)
),
NULL,
NULL,
'{}'::jsonb,
now(),
now()
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
"""
)
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute(
"""
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.create_profile_for_new_user();
"""
)
op.create_table(
"automation_jobs",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("owner_id", sa.UUID(), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column("schedule_type", sa.String(length=20), nullable=False),
sa.Column("run_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("timezone", sa.String(length=50), nullable=False),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("id", "owner_id", name="uq_automation_jobs_id_owner"),
)
op.create_index(
"ix_automation_jobs_owner_status",
"automation_jobs",
["owner_id", "status"],
unique=False,
)
op.create_index(
"ix_automation_jobs_status_next_run",
"automation_jobs",
["status", "next_run_at"],
unique=False,
)
op.execute(
"ALTER TABLE automation_jobs ADD CONSTRAINT chk_automation_job_schedule_type CHECK (schedule_type IN ('daily', 'weekly'))"
)
op.execute(
"ALTER TABLE automation_jobs ADD CONSTRAINT chk_automation_job_status CHECK (status IN ('active', 'disabled'))"
)
op.create_foreign_key(
"fk_automation_jobs_owner_id",
"automation_jobs",
"users",
["owner_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_automation_jobs_created_by",
"automation_jobs",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("automation_jobs")
op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon")
op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated")
def downgrade() -> None:
_drop_rls("automation_jobs")
op.drop_constraint(
"fk_automation_jobs_created_by", "automation_jobs", type_="foreignkey"
)
op.drop_constraint(
"fk_automation_jobs_owner_id", "automation_jobs", type_="foreignkey"
)
op.drop_table("automation_jobs")
_drop_rls("profiles")
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute("DROP FUNCTION IF EXISTS public.create_profile_for_new_user()")
op.drop_constraint("fk_profiles_id", "profiles", type_="foreignkey")
op.drop_table("profiles")
_drop_rls("llms")
op.drop_constraint("fk_llms_factory_id", "llms", type_="foreignkey")
op.drop_table("llms")
_drop_rls("llm_factory")
op.drop_table("llm_factory")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,344 @@
"""initial schema part 2: session and agent tables
Revision ID: 202602260002
Revises: 202602260001
Create Date: 2026-02-26 20:11:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "202602260002"
down_revision: Union[str, Sequence[str], None] = "202602260001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("session_type", sa.String(length=20), nullable=False),
sa.Column("job_id", sa.UUID(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column(
"last_activity_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"message_count", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"total_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"total_cost",
sa.Numeric(precision=12, scale=6),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_job_id", "sessions", ["job_id"], unique=False)
op.execute(
"CREATE INDEX ix_sessions_user_session_type_last_activity ON sessions (user_id, session_type, last_activity_at DESC)"
)
op.execute(
"ALTER TABLE sessions ADD CONSTRAINT chk_session_type CHECK (session_type IN ('chat', 'automation'))"
)
op.execute(
"ALTER TABLE sessions ADD CONSTRAINT chk_sessions_type_job_consistency CHECK ((session_type = 'chat' AND job_id IS NULL) OR (session_type = 'automation' AND job_id IS NOT NULL))"
)
op.execute(
"ALTER TABLE sessions ADD CONSTRAINT chk_sessions_status CHECK (status IN ('pending', 'running', 'completed', 'failed'))"
)
op.create_foreign_key(
"fk_sessions_user_id",
"sessions",
"users",
["user_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_sessions_job_id_user_id",
"sessions",
"automation_jobs",
["job_id", "user_id"],
["id", "owner_id"],
ondelete="RESTRICT",
)
op.create_index(
"ix_sessions_job_user",
"sessions",
["job_id", "user_id"],
unique=False,
)
_enable_rls("sessions")
op.create_table(
"messages",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("session_id", sa.UUID(), nullable=False),
sa.Column("seq", sa.Integer(), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("model_code", sa.String(length=50), nullable=True),
sa.Column("tool_name", sa.String(length=100), nullable=True),
sa.Column(
"input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column(
"output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column(
"cost",
sa.Numeric(precision=12, scale=6),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"currency",
sa.String(length=3),
nullable=False,
server_default=sa.text("'USD'"),
),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"),
)
op.create_index("ix_messages_session_id", "messages", ["session_id"], unique=False)
op.execute(
"ALTER TABLE messages ADD CONSTRAINT chk_message_role CHECK (role IN ('user', 'assistant', 'system', 'tool'))"
)
op.create_foreign_key(
"fk_messages_session_id",
"messages",
"sessions",
["session_id"],
["id"],
ondelete="CASCADE",
)
_enable_rls("messages")
op.create_table(
"user_agents",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("llm_id", sa.UUID(), nullable=False),
sa.Column("agent_type", sa.String(length=20), nullable=False),
sa.Column(
"config",
postgresql.JSONB(astext_type=sa.Text()),
server_default="{}",
nullable=False,
),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column("updated_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_user_agents_user_id"),
)
op.create_index(
"ix_user_agents_agent_type", "user_agents", ["agent_type"], unique=False
)
op.create_index("ix_user_agents_status", "user_agents", ["status"], unique=False)
op.execute(
"ALTER TABLE user_agents ADD CONSTRAINT chk_agent_type CHECK (agent_type IN ('INTENT_RECOGNITION', 'TASK_EXECUTION', 'RESULT_REPORTING'))"
)
op.create_foreign_key(
"fk_user_agents_user_id",
"user_agents",
"users",
["user_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_user_agents_llm_id",
"user_agents",
"llms",
["llm_id"],
["id"],
ondelete="RESTRICT",
)
op.create_foreign_key(
"fk_user_agents_created_by",
"user_agents",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_user_agents_updated_by",
"user_agents",
"users",
["updated_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("user_agents")
op.create_table(
"memories",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("owner_id", sa.UUID(), nullable=False),
sa.Column("agent_id", sa.UUID(), nullable=True),
sa.Column("memory_type", sa.String(length=20), nullable=False),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("source", sa.String(length=20), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_memories_owner_type_status",
"memories",
["owner_id", "memory_type", "status"],
unique=False,
)
op.create_index(
"ix_memories_agent_type_status",
"memories",
["agent_id", "memory_type", "status"],
unique=False,
)
op.execute(
"ALTER TABLE memories ADD CONSTRAINT chk_memory_type_agent_id CHECK ((memory_type = 'work' AND agent_id IS NOT NULL) OR (memory_type = 'user' AND agent_id IS NULL))"
)
op.create_foreign_key(
"fk_memories_owner_id",
"memories",
"users",
["owner_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_memories_agent_id",
"memories",
"user_agents",
["agent_id"],
["id"],
ondelete="CASCADE",
)
_enable_rls("memories")
def downgrade() -> None:
_drop_rls("memories")
op.drop_constraint("fk_memories_agent_id", "memories", type_="foreignkey")
op.drop_constraint("fk_memories_owner_id", "memories", type_="foreignkey")
op.drop_table("memories")
_drop_rls("user_agents")
op.drop_constraint("fk_user_agents_updated_by", "user_agents", type_="foreignkey")
op.drop_constraint("fk_user_agents_created_by", "user_agents", type_="foreignkey")
op.drop_constraint("fk_user_agents_llm_id", "user_agents", type_="foreignkey")
op.drop_constraint("fk_user_agents_user_id", "user_agents", type_="foreignkey")
op.drop_table("user_agents")
_drop_rls("messages")
op.drop_constraint("fk_messages_session_id", "messages", type_="foreignkey")
op.drop_table("messages")
_drop_rls("sessions")
op.drop_constraint("fk_sessions_job_id_user_id", "sessions", type_="foreignkey")
op.drop_constraint("fk_sessions_user_id", "sessions", type_="foreignkey")
op.drop_table("sessions")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,324 @@
"""initial schema part 3: social graph tables
Revision ID: 202602260003
Revises: 202602260002
Create Date: 2026-02-26 20:12:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "202602260003"
down_revision: Union[str, Sequence[str], None] = "202602260002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"friendships",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_low_id", sa.UUID(), nullable=False),
sa.Column("user_high_id", sa.UUID(), nullable=False),
sa.Column("initiator_id", sa.UUID(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("blocked_by", sa.UUID(), nullable=True),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column("updated_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_low_id", "user_high_id", name="uq_friendships_users"),
)
op.create_index(
"ix_friendships_user_low_status",
"friendships",
["user_low_id", "status"],
unique=False,
)
op.create_index(
"ix_friendships_user_high_status",
"friendships",
["user_high_id", "status"],
unique=False,
)
op.execute(
"CREATE INDEX ix_friendships_pending ON friendships (status) WHERE status = 'pending'"
)
op.execute(
"ALTER TABLE friendships ADD CONSTRAINT chk_user_low_less_than_high CHECK (user_low_id < user_high_id)"
)
op.execute(
"ALTER TABLE friendships ADD CONSTRAINT chk_initiator_id_valid CHECK (initiator_id IN (user_low_id, user_high_id))"
)
op.execute(
"ALTER TABLE friendships ADD CONSTRAINT chk_user_ids_different CHECK (user_low_id <> user_high_id)"
)
op.create_foreign_key(
"fk_friendships_user_low_id",
"friendships",
"users",
["user_low_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_friendships_user_high_id",
"friendships",
"users",
["user_high_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_friendships_initiator_id",
"friendships",
"users",
["initiator_id"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_friendships_created_by",
"friendships",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_friendships_updated_by",
"friendships",
"users",
["updated_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("friendships")
op.create_table(
"groups",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("owner_id", sa.UUID(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column("updated_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_groups_owner_status", "groups", ["owner_id", "status"], unique=False
)
op.create_foreign_key(
"fk_groups_owner_id",
"groups",
"users",
["owner_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_groups_created_by",
"groups",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_groups_updated_by",
"groups",
"users",
["updated_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("groups")
op.create_table(
"group_members",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("group_id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("join_source", sa.String(length=20), nullable=False),
sa.Column("invited_by", sa.UUID(), nullable=True),
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column("updated_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_group_user"),
)
op.create_index(
"ix_group_members_group_role_status",
"group_members",
["group_id", "role", "status"],
unique=False,
)
op.create_index(
"ix_group_members_user_status",
"group_members",
["user_id", "status"],
unique=False,
)
op.execute(
"ALTER TABLE group_members ADD CONSTRAINT chk_group_member_role CHECK (role IN ('owner', 'admin', 'member'))"
)
op.create_foreign_key(
"fk_group_members_group_id",
"group_members",
"groups",
["group_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_group_members_user_id",
"group_members",
"users",
["user_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_group_members_invited_by",
"group_members",
"users",
["invited_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_group_members_created_by",
"group_members",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_group_members_updated_by",
"group_members",
"users",
["updated_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("group_members")
def downgrade() -> None:
_drop_rls("group_members")
op.drop_constraint(
"fk_group_members_updated_by", "group_members", type_="foreignkey"
)
op.drop_constraint(
"fk_group_members_created_by", "group_members", type_="foreignkey"
)
op.drop_constraint(
"fk_group_members_invited_by", "group_members", type_="foreignkey"
)
op.drop_constraint("fk_group_members_user_id", "group_members", type_="foreignkey")
op.drop_constraint("fk_group_members_group_id", "group_members", type_="foreignkey")
op.drop_table("group_members")
_drop_rls("groups")
op.drop_constraint("fk_groups_updated_by", "groups", type_="foreignkey")
op.drop_constraint("fk_groups_created_by", "groups", type_="foreignkey")
op.drop_constraint("fk_groups_owner_id", "groups", type_="foreignkey")
op.drop_table("groups")
_drop_rls("friendships")
op.drop_constraint("fk_friendships_updated_by", "friendships", type_="foreignkey")
op.drop_constraint("fk_friendships_created_by", "friendships", type_="foreignkey")
op.drop_constraint("fk_friendships_initiator_id", "friendships", type_="foreignkey")
op.drop_constraint("fk_friendships_user_high_id", "friendships", type_="foreignkey")
op.drop_constraint("fk_friendships_user_low_id", "friendships", type_="foreignkey")
op.drop_table("friendships")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,488 @@
"""initial schema part 4: collaboration tables
Revision ID: 202602260004
Revises: 202602260003
Create Date: 2026-02-26 20:13:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "202602260004"
down_revision: Union[str, Sequence[str], None] = "202602260003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"schedule_items",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("owner_id", sa.UUID(), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("timezone", sa.String(length=50), nullable=False),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
server_default="{}",
nullable=False,
),
sa.Column("recurrence_rule", sa.String(length=255), nullable=True),
sa.Column("source_type", sa.String(length=20), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_schedule_items_owner_start",
"schedule_items",
["owner_id", "start_at"],
unique=False,
)
op.create_index(
"ix_schedule_items_status_start",
"schedule_items",
["status", "start_at"],
unique=False,
)
op.execute(
"ALTER TABLE schedule_items ADD CONSTRAINT chk_schedule_item_source_type CHECK (source_type IN ('manual', 'imported', 'agent_generated'))"
)
op.execute(
"ALTER TABLE schedule_items ADD CONSTRAINT chk_schedule_item_status CHECK (status IN ('active', 'completed', 'canceled', 'archived'))"
)
op.create_foreign_key(
"fk_schedule_items_owner_id",
"schedule_items",
"users",
["owner_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_schedule_items_created_by",
"schedule_items",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("schedule_items")
op.create_table(
"schedule_subscriptions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("item_id", sa.UUID(), nullable=False),
sa.Column("subscriber_id", sa.UUID(), nullable=False),
sa.Column(
"permission", sa.Integer(), nullable=False, server_default=sa.text("1")
),
sa.Column(
"notify_level",
sa.String(length=20),
nullable=False,
server_default=sa.text("'all'"),
),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default=sa.text("'active'"),
),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"item_id", "subscriber_id", name="uq_schedule_subscriptions_item_subscriber"
),
)
op.create_index(
"ix_schedule_subscribers_subscriber_status",
"schedule_subscriptions",
["subscriber_id", "status"],
unique=False,
)
op.create_index(
"ix_schedule_subscribers_item_status",
"schedule_subscriptions",
["item_id", "status"],
unique=False,
)
op.execute(
"ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_permission CHECK (permission BETWEEN 0 AND 7)"
)
op.execute(
"ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_notify_level CHECK (notify_level IN ('all', 'mentions', 'none'))"
)
op.execute(
"ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_status CHECK (status IN ('active', 'paused', 'unsubscribed'))"
)
op.create_foreign_key(
"fk_schedule_subscriptions_item_id",
"schedule_subscriptions",
"schedule_items",
["item_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_schedule_subscriptions_subscriber_id",
"schedule_subscriptions",
"users",
["subscriber_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_schedule_subscriptions_created_by",
"schedule_subscriptions",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("schedule_subscriptions")
op.create_table(
"todos",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("owner_id", sa.UUID(), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.String(length=1000), nullable=True),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("priority", sa.Integer(), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_todos_owner_status_due",
"todos",
["owner_id", "status", "due_at"],
unique=False,
)
op.create_index(
"ix_todos_owner_created", "todos", ["owner_id", "created_at"], unique=False
)
op.execute(
"ALTER TABLE todos ADD CONSTRAINT chk_todos_status CHECK (status IN ('pending', 'done', 'canceled'))"
)
op.execute(
"ALTER TABLE todos ADD CONSTRAINT chk_todos_priority CHECK (priority BETWEEN 1 AND 4)"
)
op.create_foreign_key(
"fk_todos_owner_id",
"todos",
"users",
["owner_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_todos_created_by",
"todos",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("todos")
op.create_table(
"todo_sources",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("todo_id", sa.UUID(), nullable=False),
sa.Column("schedule_item_id", sa.UUID(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"todo_id", "schedule_item_id", name="uq_todo_sources_todo_schedule"
),
)
op.create_index("ix_todo_sources_todo", "todo_sources", ["todo_id"], unique=False)
op.create_index(
"ix_todo_sources_schedule_item",
"todo_sources",
["schedule_item_id"],
unique=False,
)
op.create_foreign_key(
"fk_todo_sources_todo_id",
"todo_sources",
"todos",
["todo_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_todo_sources_schedule_item_id",
"todo_sources",
"schedule_items",
["schedule_item_id"],
["id"],
ondelete="CASCADE",
)
_enable_rls("todo_sources")
op.create_table(
"inbox_messages",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("recipient_id", sa.UUID(), nullable=False),
sa.Column("sender_id", sa.UUID(), nullable=True),
sa.Column("message_type", sa.String(length=20), nullable=False),
sa.Column("friendship_id", sa.UUID(), nullable=True),
sa.Column("schedule_item_id", sa.UUID(), nullable=True),
sa.Column("group_id", sa.UUID(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column(
"is_read", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_inbox_messages_recipient_status_created",
"inbox_messages",
["recipient_id", "status", "created_at"],
unique=False,
)
op.execute(
"ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_type CHECK (message_type IN ('friend_request', 'calendar', 'system', 'group'))"
)
op.execute(
"ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_status CHECK (status IN ('pending', 'accepted', 'rejected', 'dismissed'))"
)
op.execute(
"ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_sender CHECK ((message_type = 'system' AND sender_id IS NULL) OR (message_type <> 'system' AND sender_id IS NOT NULL))"
)
op.execute(
"ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_friendship CHECK ((message_type = 'friend_request' AND friendship_id IS NOT NULL) OR (message_type <> 'friend_request' AND friendship_id IS NULL))"
)
op.execute(
"ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_schedule_item CHECK ((message_type = 'calendar' AND schedule_item_id IS NOT NULL) OR (message_type <> 'calendar' AND schedule_item_id IS NULL))"
)
op.execute(
"ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_group CHECK ((message_type = 'group' AND group_id IS NOT NULL) OR (message_type <> 'group' AND group_id IS NULL))"
)
op.execute(
"ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_system_fields CHECK ((message_type = 'system' AND friendship_id IS NULL AND schedule_item_id IS NULL AND group_id IS NULL) OR (message_type <> 'system' AND (friendship_id IS NOT NULL OR schedule_item_id IS NOT NULL OR group_id IS NOT NULL)))"
)
op.create_foreign_key(
"fk_inbox_messages_recipient_id",
"inbox_messages",
"users",
["recipient_id"],
["id"],
referent_schema="auth",
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_inbox_messages_sender_id",
"inbox_messages",
"users",
["sender_id"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_inbox_messages_friendship_id",
"inbox_messages",
"friendships",
["friendship_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_inbox_messages_schedule_item_id",
"inbox_messages",
"schedule_items",
["schedule_item_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_inbox_messages_group_id",
"inbox_messages",
"groups",
["group_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_inbox_messages_created_by",
"inbox_messages",
"users",
["created_by"],
["id"],
referent_schema="auth",
ondelete="SET NULL",
)
_enable_rls("inbox_messages")
def downgrade() -> None:
_drop_rls("inbox_messages")
op.drop_constraint(
"fk_inbox_messages_created_by", "inbox_messages", type_="foreignkey"
)
op.drop_constraint(
"fk_inbox_messages_group_id", "inbox_messages", type_="foreignkey"
)
op.drop_constraint(
"fk_inbox_messages_schedule_item_id", "inbox_messages", type_="foreignkey"
)
op.drop_constraint(
"fk_inbox_messages_friendship_id", "inbox_messages", type_="foreignkey"
)
op.drop_constraint(
"fk_inbox_messages_sender_id", "inbox_messages", type_="foreignkey"
)
op.drop_constraint(
"fk_inbox_messages_recipient_id", "inbox_messages", type_="foreignkey"
)
op.drop_table("inbox_messages")
_drop_rls("todo_sources")
op.drop_constraint(
"fk_todo_sources_schedule_item_id", "todo_sources", type_="foreignkey"
)
op.drop_constraint("fk_todo_sources_todo_id", "todo_sources", type_="foreignkey")
op.drop_table("todo_sources")
_drop_rls("todos")
op.drop_constraint("fk_todos_created_by", "todos", type_="foreignkey")
op.drop_constraint("fk_todos_owner_id", "todos", type_="foreignkey")
op.drop_table("todos")
_drop_rls("schedule_subscriptions")
op.drop_constraint(
"fk_schedule_subscriptions_created_by",
"schedule_subscriptions",
type_="foreignkey",
)
op.drop_constraint(
"fk_schedule_subscriptions_subscriber_id",
"schedule_subscriptions",
type_="foreignkey",
)
op.drop_constraint(
"fk_schedule_subscriptions_item_id",
"schedule_subscriptions",
type_="foreignkey",
)
op.drop_table("schedule_subscriptions")
_drop_rls("schedule_items")
op.drop_constraint(
"fk_schedule_items_created_by", "schedule_items", type_="foreignkey"
)
op.drop_constraint(
"fk_schedule_items_owner_id", "schedule_items", type_="foreignkey"
)
op.drop_table("schedule_items")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}")
op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,29 @@
"""initial schema part 5: hardening and partial indexes
Revision ID: 202602260005
Revises: 202602260004
Create Date: 2026-02-26 20:14:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "202602260005"
down_revision: Union[str, Sequence[str], None] = "202602260004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"CREATE INDEX ix_inbox_messages_pending_recent ON inbox_messages (recipient_id, created_at DESC) WHERE status = 'pending'"
)
op.execute(
"CREATE INDEX ix_todos_pending_due ON todos (owner_id, due_at) WHERE status = 'pending'"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_todos_pending_due")
op.execute("DROP INDEX IF EXISTS ix_inbox_messages_pending_recent")
+20
View File
@@ -2,14 +2,34 @@ from __future__ import annotations
from models.agent_chat_message import AgentChatMessage from models.agent_chat_message import AgentChatMessage
from models.agent_chat_session import AgentChatSession from models.agent_chat_session import AgentChatSession
from models.automation_jobs import AutomationJob
from models.group_members import GroupMember
from models.groups import Group
from models.inbox_messages import InboxMessage
from models.llm import Llm from models.llm import Llm
from models.llm_factory import LlmFactory from models.llm_factory import LlmFactory
from models.memories import Memory
from models.profile import Profile from models.profile import Profile
from models.schedule_items import ScheduleItem
from models.schedule_subscriptions import ScheduleSubscription
from models.todos import Todo
from models.todo_sources import TodoSource
from models.user_agents import UserAgent
__all__ = [ __all__ = [
"AgentChatMessage", "AgentChatMessage",
"AgentChatSession", "AgentChatSession",
"AutomationJob",
"GroupMember",
"Group",
"InboxMessage",
"Llm", "Llm",
"LlmFactory", "LlmFactory",
"Memory",
"Profile", "Profile",
"ScheduleItem",
"ScheduleSubscription",
"Todo",
"TodoSource",
"UserAgent",
] ]
+15 -2
View File
@@ -8,7 +8,6 @@ from enum import Enum
from sqlalchemy import ( from sqlalchemy import (
DateTime, DateTime,
Enum as SqlEnum, Enum as SqlEnum,
ForeignKey,
Integer, Integer,
Numeric, Numeric,
String, String,
@@ -28,18 +27,32 @@ class AgentChatSessionStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
class SessionType(str, Enum):
CHAT = "chat"
AUTOMATION = "automation"
class AgentChatSession(TimestampMixin, SoftDeleteMixin, Base): class AgentChatSession(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "sessions" __tablename__: str = "sessions"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
) )
user_id: Mapped[uuid.UUID] = mapped_column( user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("auth.users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True, index=True,
) )
session_type: Mapped[SessionType] = mapped_column(
String(20),
nullable=False,
default=SessionType.CHAT,
)
job_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True) title: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[AgentChatSessionStatus] = mapped_column( status: Mapped[AgentChatSessionStatus] = mapped_column(
SqlEnum( SqlEnum(
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
class AutomationJobStatus(str, Enum):
ACTIVE = "active"
DISABLED = "disabled"
class ScheduleType(str, Enum):
DAILY = "daily"
WEEKLY = "weekly"
class AutomationJob(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "automation_jobs"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
title: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
prompt: Mapped[str] = mapped_column(
Text,
nullable=False,
)
schedule_type: Mapped[ScheduleType] = mapped_column(
String(20),
nullable=False,
)
run_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
next_run_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
timezone: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="UTC",
)
last_run_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
status: Mapped[AutomationJobStatus] = mapped_column(
String(20),
nullable=False,
default=AutomationJobStatus.ACTIVE,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import uuid
from enum import Enum
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
class FriendshipStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
BLOCKED = "blocked"
DECLINED = "declined"
CANCELED = "canceled"
class Friendship(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "friendships"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_low_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
user_high_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
initiator_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
status: Mapped[FriendshipStatus] = mapped_column(
String(20),
nullable=False,
default=FriendshipStatus.PENDING,
)
requested_at: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
accepted_at: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
blocked_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import uuid
from enum import Enum
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
class GroupMemberRole(str, Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
class GroupMemberSource(str, Enum):
INVITED = "invited"
JOINED = "joined"
class GroupMemberStatus(str, Enum):
ACTIVE = "active"
MUTED = "muted"
REMOVED = "removed"
class GroupMember(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "group_members"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
group_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
role: Mapped[GroupMemberRole] = mapped_column(
String(20),
nullable=False,
)
join_source: Mapped[GroupMemberSource] = mapped_column(
String(20),
nullable=False,
)
invited_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
joined_at: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
removed_at: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
status: Mapped[GroupMemberStatus] = mapped_column(
String(20),
nullable=False,
default=GroupMemberStatus.ACTIVE,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import uuid
from enum import Enum
from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
class GroupStatus(str, Enum):
ACTIVE = "active"
ARCHIVED = "archived"
class Group(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "groups"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(
String(100),
nullable=False,
)
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
status: Mapped[GroupStatus] = mapped_column(
String(20),
nullable=False,
default=GroupStatus.ACTIVE,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import uuid
from enum import Enum
from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class InboxMessageType(str, Enum):
FRIEND_REQUEST = "friend_request"
CALENDAR = "calendar"
SYSTEM = "system"
GROUP = "group"
class InboxMessageStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
DISMISSED = "dismissed"
class InboxMessage(TimestampMixin, Base):
__tablename__: str = "inbox_messages"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
recipient_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
sender_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
message_type: Mapped[InboxMessageType] = mapped_column(
String(20),
nullable=False,
)
friendship_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
schedule_item_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
group_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
content: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
is_read: Mapped[bool] = mapped_column(
String(10),
nullable=False,
default=False,
)
status: Mapped[InboxMessageStatus] = mapped_column(
String(20),
nullable=False,
default=InboxMessageStatus.PENDING,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import uuid
from enum import Enum
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class MemoryType(str, Enum):
USER = "user"
WORK = "work"
class MemorySource(str, Enum):
MANUAL = "manual"
AGENT = "agent"
IMPORTED = "imported"
class MemoryStatus(str, Enum):
ACTIVE = "active"
DISABLED = "disabled"
class Memory(TimestampMixin, Base):
__tablename__: str = "memories"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
agent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
memory_type: Mapped[MemoryType] = mapped_column(
String(20),
nullable=False,
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
content: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
)
source: Mapped[MemorySource] = mapped_column(
String(20),
nullable=False,
)
status: Mapped[MemoryStatus] = mapped_column(
String(20),
nullable=False,
default=MemoryStatus.ACTIVE,
)
+8 -3
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
import uuid import uuid
from sqlalchemy import ForeignKey, String, Text from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin from core.db.base import Base, SoftDeleteMixin, TimestampMixin
@@ -18,10 +18,10 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base):
""" """
__tablename__: str = "profiles" __tablename__: str = "profiles"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("auth.users.id", ondelete="CASCADE"),
primary_key=True, primary_key=True,
) )
username: Mapped[str] = mapped_column( username: Mapped[str] = mapped_column(
@@ -37,3 +37,8 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base):
String(200), String(200),
nullable=True, nullable=True,
) )
settings: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
server_default="{}",
)
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
class ScheduleItemStatus(str, Enum):
ACTIVE = "active"
COMPLETED = "completed"
CANCELED = "canceled"
ARCHIVED = "archived"
class ScheduleItemSourceType(str, Enum):
MANUAL = "manual"
IMPORTED = "imported"
AGENT_GENERATED = "agent_generated"
class ScheduleItem(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "schedule_items"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
title: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
start_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
end_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
timezone: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="UTC",
)
extra_metadata: Mapped[dict] = mapped_column(
"metadata",
JSONB,
nullable=False,
server_default="{}",
)
recurrence_rule: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
)
source_type: Mapped[ScheduleItemSourceType] = mapped_column(
String(20),
nullable=False,
default=ScheduleItemSourceType.MANUAL,
)
status: Mapped[ScheduleItemStatus] = mapped_column(
String(20),
nullable=False,
default=ScheduleItemStatus.ACTIVE,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
@@ -0,0 +1,58 @@
from __future__ import annotations
import uuid
from enum import Enum
from sqlalchemy import Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class SubscriptionStatus(str, Enum):
ACTIVE = "active"
PAUSED = "paused"
UNSUBSCRIBED = "unsubscribed"
class NotifyLevel(str, Enum):
ALL = "all"
MENTIONS = "mentions"
NONE = "none"
class ScheduleSubscription(TimestampMixin, Base):
__tablename__: str = "schedule_subscriptions"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
item_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
subscriber_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
permission: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
)
notify_level: Mapped[NotifyLevel] = mapped_column(
String(20),
nullable=False,
default=NotifyLevel.ALL,
)
status: Mapped[SubscriptionStatus] = mapped_column(
String(20),
nullable=False,
default=SubscriptionStatus.ACTIVE,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import uuid
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class TodoSource(TimestampMixin, Base):
__tablename__: str = "todo_sources"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
todo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
schedule_item_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
class TodoStatus(str, Enum):
PENDING = "pending"
DONE = "done"
CANCELED = "canceled"
class TodoPriority(int, Enum):
IMPORTANT_URGENT = 1
IMPORTANT_NOT_URGENT = 2
NOT_IMPORTANT_URGENT = 3
NOT_IMPORTANT_NOT_URGENT = 4
class Todo(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "todos"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
title: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
description: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
)
due_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
priority: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=TodoPriority.NOT_IMPORTANT_NOT_URGENT,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
status: Mapped[TodoStatus] = mapped_column(
String(20),
nullable=False,
default=TodoStatus.PENDING,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import uuid
from enum import Enum
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
class UserAgentStatus(str, Enum):
ACTIVE = "active"
PAUSED = "paused"
MIGRATING = "migrating"
class AgentType(str, Enum):
INTENT_RECOGNITION = "INTENT_RECOGNITION"
TASK_EXECUTION = "TASK_EXECUTION"
RESULT_REPORTING = "RESULT_REPORTING"
class UserAgent(TimestampMixin, SoftDeleteMixin, Base):
__tablename__: str = "user_agents"
__table_args__ = {"extend_existing": True}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
unique=True,
)
llm_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
agent_type: Mapped[AgentType] = mapped_column(
String(20),
nullable=False,
)
config: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
server_default="{}",
)
status: Mapped[UserAgentStatus] = mapped_column(
String(20),
nullable=False,
default=UserAgentStatus.ACTIVE,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
@@ -3,15 +3,25 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
def test_agent_chat_migration_exists_and_creates_expected_tables() -> None: def test_initial_migration_exists_and_creates_expected_tables() -> None:
versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions" versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions"
migration = versions_dir / "20260226_create_agent_chat_core_tables.py" migration_files = sorted(versions_dir.glob("20260226_*.py"))
assert len(migration_files) == 5, "split initial migrations should exist"
assert migration.exists() content = "\n".join(m.read_text(encoding="utf-8") for m in migration_files)
content = migration.read_text(encoding="utf-8") # New tables from social data model redesign
assert 'create_table(\n "llm_factory"' in content assert "create_table(" in content and "automation_jobs" in content
assert 'create_table(\n "llms"' in content assert "create_table(" in content and "user_agents" in content
assert 'create_table(\n "sessions"' in content assert "create_table(" in content and "memories" in content
assert 'create_table(\n "messages"' in content assert "create_table(" in content and "friendships" in content
assert "tool_calls" not in content assert "create_table(" in content and "groups" in content
assert "create_table(" in content and "group_members" in content
assert "create_table(" in content and "schedule_items" in content
assert "create_table(" in content and "schedule_subscriptions" in content
assert "create_table(" in content and "inbox_messages" in content
assert "create_table(" in content and "todos" in content
assert "create_table(" in content and "todo_sources" in content
assert "create_table(" in content and "profiles" in content
assert "create_table(" in content and "sessions" in content
assert "create_table(" in content and "messages" in content
@@ -3,14 +3,13 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
def test_drop_display_name_migration_exists_and_uses_username_metadata() -> None: def test_profiles_has_settings_column() -> None:
"""Verify profiles and auth trigger are defined in split migrations."""
versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions" versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions"
migration = ( migration_files = sorted(versions_dir.glob("20260226_*.py"))
versions_dir / "20260224_drop_profile_display_name_and_trigger_username.py" content = "\n".join(m.read_text(encoding="utf-8") for m in migration_files)
)
assert migration.exists() assert 'create_table(\n "profiles"' in content
assert '"settings"' in content
content = migration.read_text(encoding="utf-8") assert "create_profile_for_new_user" in content
assert "DROP COLUMN" in content and "display_name" in content assert "on_auth_user_created" in content
assert "raw_user_meta_data->>'username'" in content