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
AgentChatMessage,
AgentChatSession,
AutomationJob,
Group,
GroupMember,
InboxMessage,
Llm,
LlmFactory,
Memory,
Profile,
ScheduleItem,
ScheduleSubscription,
Todo,
TodoSource,
UserAgent,
)
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")