diff --git a/alembic/env.py b/alembic/env.py index 2d72073..93fca0f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -39,6 +39,7 @@ def run_migrations_offline() -> None: literal_binds=True, dialect_opts={"paramstyle": "named"}, compare_type=True, + include_schemas=True, ) with context.begin_transaction(): @@ -52,6 +53,7 @@ def do_run_migrations(connection: Connection) -> None: connection=connection, target_metadata=target_metadata, compare_type=True, + include_schemas=True, ) with context.begin_transaction(): diff --git a/alembic/versions/021805c35a4a_create_enterprise_schemas.py b/alembic/versions/021805c35a4a_create_enterprise_schemas.py new file mode 100644 index 0000000..2a4b25f --- /dev/null +++ b/alembic/versions/021805c35a4a_create_enterprise_schemas.py @@ -0,0 +1,59 @@ +"""create enterprise schemas + +Revision ID: 021805c35a4a +Revises: 487eb0ed5647 +Create Date: 2026-07-19 16:04:06.801291 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '021805c35a4a' +down_revision: Union[str, Sequence[str], None] = '487eb0ed5647' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +from alembic import op + + +def upgrade() -> None: + schemas = [ + "auth", + "market", + "sentiment", + "strategy", + "risk", + "execution", + "learning", + "discipline", + "analytics", + "audit", + "system", + ] + + for schema in schemas: + op.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema}"') + + +def downgrade() -> None: + schemas = [ + "system", + "audit", + "analytics", + "discipline", + "learning", + "execution", + "risk", + "strategy", + "sentiment", + "market", + "auth", + ] + + for schema in schemas: + op.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') diff --git a/alembic/versions/13a89530c1d2_move_auth_tables_to_auth_schema.py b/alembic/versions/13a89530c1d2_move_auth_tables_to_auth_schema.py new file mode 100644 index 0000000..dd5a592 --- /dev/null +++ b/alembic/versions/13a89530c1d2_move_auth_tables_to_auth_schema.py @@ -0,0 +1,39 @@ +"""move auth tables to auth schema + +Revision ID: 13a89530c1d2 +Revises: 021805c35a4a +Create Date: 2026-07-19 16:41:04.715671 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '13a89530c1d2' +down_revision: Union[str, Sequence[str], None] = '021805c35a4a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Move authentication tables to the auth schema.""" + + op.execute("ALTER TABLE public.users SET SCHEMA auth") + op.execute("ALTER TABLE public.refresh_tokens SET SCHEMA auth") + op.execute("ALTER TABLE public.password_reset_tokens SET SCHEMA auth") + op.execute("ALTER TABLE public.email_verification_tokens SET SCHEMA auth") + + op.execute("ALTER TYPE public.user_role SET SCHEMA auth") + +def downgrade() -> None: + """Move authentication tables back to the public schema.""" + + op.execute("ALTER TABLE auth.email_verification_tokens SET SCHEMA public") + op.execute("ALTER TABLE auth.password_reset_tokens SET SCHEMA public") + op.execute("ALTER TABLE auth.refresh_tokens SET SCHEMA public") + op.execute("ALTER TABLE auth.users SET SCHEMA public") + + op.execute("ALTER TYPE auth.user_role SET SCHEMA public") diff --git a/docs/architecture/multi-schema-plan.md b/docs/architecture/multi-schema-plan.md new file mode 100644 index 0000000..c0fa4cf --- /dev/null +++ b/docs/architecture/multi-schema-plan.md @@ -0,0 +1,19 @@ +# Multi Schema Plan + +Current + +public + +Future + +auth +market +sentiment +strategy +risk +execution +learning +discipline +analytics +audit +system diff --git a/src/ai_trading_discipline_copilot/models/email_verification_token.py b/src/ai_trading_discipline_copilot/models/email_verification_token.py index b027ec6..de13074 100644 --- a/src/ai_trading_discipline_copilot/models/email_verification_token.py +++ b/src/ai_trading_discipline_copilot/models/email_verification_token.py @@ -20,7 +20,7 @@ class EmailVerificationToken(Base): """Stores one-time email verification tokens.""" __tablename__ = "email_verification_tokens" - + __table_args__ = {"schema": "auth"} id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, @@ -29,7 +29,7 @@ class EmailVerificationToken(Base): user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("users.id", ondelete="CASCADE"), + ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False, index=True, ) diff --git a/src/ai_trading_discipline_copilot/models/password_reset_token.py b/src/ai_trading_discipline_copilot/models/password_reset_token.py index c248e06..c024b7d 100644 --- a/src/ai_trading_discipline_copilot/models/password_reset_token.py +++ b/src/ai_trading_discipline_copilot/models/password_reset_token.py @@ -20,7 +20,7 @@ class PasswordResetToken(Base): """Stores one-time password reset tokens.""" __tablename__ = "password_reset_tokens" - + __table_args__ = {"schema": "auth"} id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, @@ -29,7 +29,7 @@ class PasswordResetToken(Base): user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("users.id", ondelete="CASCADE"), + ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False, index=True, ) diff --git a/src/ai_trading_discipline_copilot/models/refresh_token.py b/src/ai_trading_discipline_copilot/models/refresh_token.py index 537c315..9967074 100644 --- a/src/ai_trading_discipline_copilot/models/refresh_token.py +++ b/src/ai_trading_discipline_copilot/models/refresh_token.py @@ -18,7 +18,7 @@ class RefreshToken(Base): """Refresh token session for a user.""" __tablename__ = "refresh_tokens" - + __table_args__ = {"schema": "auth"} id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, @@ -27,7 +27,7 @@ class RefreshToken(Base): user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("users.id", ondelete="CASCADE"), + ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False, ) diff --git a/src/ai_trading_discipline_copilot/models/user.py b/src/ai_trading_discipline_copilot/models/user.py index ae81c56..2bbcc96 100644 --- a/src/ai_trading_discipline_copilot/models/user.py +++ b/src/ai_trading_discipline_copilot/models/user.py @@ -27,6 +27,7 @@ class UserRole(enum.StrEnum): class User(Base): __tablename__ = "users" + __table_args__ = {"schema": "auth"} id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), @@ -63,7 +64,11 @@ class User(Base): cascade="all, delete-orphan", ) role: Mapped[UserRole] = mapped_column( - Enum(UserRole, name="user_role"), + Enum( + UserRole, + name="user_role", + schema="auth", + ), default=UserRole.USER, nullable=False, ) diff --git a/tests/fixtures/database.py b/tests/fixtures/database.py index 761712b..bc4484e 100644 --- a/tests/fixtures/database.py +++ b/tests/fixtures/database.py @@ -35,6 +35,22 @@ async def create_test_db() -> None: await engine.dispose() +# Enterprise PostgreSQL schemas used by the application. +SCHEMAS = ( + "auth", + "market", + "sentiment", + "strategy", + "risk", + "execution", + "learning", + "discipline", + "analytics", + "audit", + "system", +) + + @pytest.fixture async def db_engine() -> AsyncGenerator[AsyncEngine]: """Create a database engine scoped to the test's event loop.""" @@ -44,7 +60,12 @@ async def db_engine() -> AsyncGenerator[AsyncEngine]: # Recreate tables for every test to ensure a clean state and avoid loop mismatch async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) + for schema in SCHEMAS: + await conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')) + + for schema in SCHEMAS: + await conn.execute(text(f'CREATE SCHEMA "{schema}"')) + await conn.run_sync(Base.metadata.create_all) yield engine