feat(release): add verified image publication pipeline
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""initial portfolio schema
|
||||
|
||||
Revision ID: 20260403_0001
|
||||
Revises:
|
||||
Create Date: 2026-04-03 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260403_0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"strategy_sets",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("strategy_name", sa.Text(), nullable=False),
|
||||
sa.Column("strategy_config", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.UniqueConstraint("strategy_name", "strategy_config", name="uq_strategy_sets_name_config"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"portfolios",
|
||||
sa.Column("portfolio_id", sa.Text(), primary_key=True),
|
||||
sa.Column("broker", sa.Text(), nullable=False, server_default="kis"),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="enabled"),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("strategy_name", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("strategy_params", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("cash_balance", sa.Float(), nullable=False),
|
||||
sa.Column("asset_quantity", sa.Float(), nullable=False),
|
||||
sa.Column("asset_price", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("profit_buffer", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("current_strategy_set_id", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("last_trade_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"portfolio_ledger",
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("action", sa.Text(), nullable=False),
|
||||
sa.Column("price", sa.Float(), nullable=False),
|
||||
sa.Column("quantity", sa.Float(), nullable=False),
|
||||
sa.Column("fee_amount", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("tax_amount", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("cash_balance_after", sa.Float(), nullable=False),
|
||||
sa.Column("asset_quantity_after", sa.Float(), nullable=False),
|
||||
sa.Column("total_value_after", sa.Float(), nullable=False),
|
||||
sa.Column("profit_buffer_after", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("strategy_set_id", sa.Text(), nullable=False, server_default="legacy"),
|
||||
sa.Column("note", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_portfolio_ledger_portfolio_time",
|
||||
"portfolio_ledger",
|
||||
["portfolio_id", sa.text("recorded_at DESC")],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_portfolios_broker_status_updated",
|
||||
"portfolios",
|
||||
["broker", "status", sa.text("updated_at DESC")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
bind = op.get_bind()
|
||||
has_timescaledb = bind.execute(sa.text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")).scalar()
|
||||
if has_timescaledb:
|
||||
bind.execute(sa.text("SELECT create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_portfolios_broker_status_updated", table_name="portfolios")
|
||||
op.drop_index("idx_portfolio_ledger_portfolio_time", table_name="portfolio_ledger")
|
||||
op.drop_table("portfolio_ledger")
|
||||
op.drop_table("portfolios")
|
||||
op.drop_table("strategy_sets")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""rename strategy_set columns and add nick
|
||||
|
||||
Revision ID: 20260403_0002
|
||||
Revises: 20260403_0001
|
||||
Create Date: 2026-04-03 00:30:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260403_0002"
|
||||
down_revision = "20260403_0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _drop_unique_constraint_for_columns(table_name: str, columns: tuple[str, ...]) -> None:
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT c.conname,
|
||||
array_agg(a.attname ORDER BY u.ord) AS column_names
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN unnest(c.conkey) WITH ORDINALITY AS u(attnum, ord) ON TRUE
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = u.attnum
|
||||
WHERE c.contype = 'u'
|
||||
AND n.nspname = current_schema()
|
||||
AND t.relname = :table_name
|
||||
GROUP BY c.conname
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
expected = list(columns)
|
||||
for row in rows:
|
||||
if list(row.column_names) == expected:
|
||||
op.drop_constraint(row.conname, table_name, type_="unique")
|
||||
return
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("strategy_sets", sa.Column("name", sa.Text(), nullable=True))
|
||||
op.add_column("strategy_sets", sa.Column("config", sa.Text(), nullable=True, server_default=""))
|
||||
op.add_column("strategy_sets", sa.Column("nick", sa.Text(), nullable=True))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE strategy_sets
|
||||
SET
|
||||
name = strategy_name,
|
||||
config = strategy_config,
|
||||
nick = strategy_name || ':' || substr(md5(strategy_config), 1, 12)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.alter_column("strategy_sets", "name", nullable=False)
|
||||
op.alter_column("strategy_sets", "config", nullable=False)
|
||||
op.alter_column("strategy_sets", "nick", nullable=False)
|
||||
|
||||
_drop_unique_constraint_for_columns("strategy_sets", ("strategy_name", "strategy_config"))
|
||||
op.create_unique_constraint("uq_strategy_sets_name_config", "strategy_sets", ["name", "config"])
|
||||
op.create_unique_constraint("uq_strategy_sets_nick", "strategy_sets", ["nick"])
|
||||
|
||||
op.drop_column("strategy_sets", "strategy_name")
|
||||
op.drop_column("strategy_sets", "strategy_config")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("strategy_sets", sa.Column("strategy_name", sa.Text(), nullable=True))
|
||||
op.add_column("strategy_sets", sa.Column("strategy_config", sa.Text(), nullable=True, server_default=""))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE strategy_sets
|
||||
SET
|
||||
strategy_name = name,
|
||||
strategy_config = config
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.alter_column("strategy_sets", "strategy_name", nullable=False)
|
||||
op.alter_column("strategy_sets", "strategy_config", nullable=False)
|
||||
|
||||
_drop_unique_constraint_for_columns("strategy_sets", ("nick",))
|
||||
_drop_unique_constraint_for_columns("strategy_sets", ("name", "config"))
|
||||
op.create_unique_constraint("uq_strategy_sets_name_config", "strategy_sets", ["strategy_name", "strategy_config"])
|
||||
|
||||
op.drop_column("strategy_sets", "nick")
|
||||
op.drop_column("strategy_sets", "name")
|
||||
op.drop_column("strategy_sets", "config")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""replace embedded portfolio strategy fields with strategy references
|
||||
|
||||
Revision ID: 20260403_0003
|
||||
Revises: 20260403_0002
|
||||
Create Date: 2026-04-03 01:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260403_0003"
|
||||
down_revision = "20260403_0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("portfolios", sa.Column("active_strategy_id", sa.Text(), nullable=True))
|
||||
op.add_column("portfolios", sa.Column("avabile_strategies", postgresql.ARRAY(sa.Text()), nullable=True))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET
|
||||
active_strategy_id = current_strategy_set_id,
|
||||
avabile_strategies = CASE
|
||||
WHEN current_strategy_set_id IS NULL THEN NULL
|
||||
ELSE ARRAY[current_strategy_set_id]::text[]
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.drop_column("portfolios", "strategy_name")
|
||||
op.drop_column("portfolios", "strategy_params")
|
||||
op.drop_column("portfolios", "current_strategy_set_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("portfolios", sa.Column("strategy_name", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column(
|
||||
"portfolios",
|
||||
sa.Column(
|
||||
"strategy_params",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.add_column("portfolios", sa.Column("current_strategy_set_id", sa.Text(), nullable=True))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE portfolios p
|
||||
SET
|
||||
current_strategy_set_id = p.active_strategy_id,
|
||||
strategy_name = COALESCE(s.name, ''),
|
||||
strategy_params = CASE
|
||||
WHEN s.config IS NULL OR s.config = '' THEN '{}'::jsonb
|
||||
ELSE s.config::jsonb
|
||||
END
|
||||
FROM strategy_sets s
|
||||
WHERE p.active_strategy_id IS NOT NULL
|
||||
AND s.id = p.active_strategy_id
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET strategy_name = COALESCE(strategy_name, ''),
|
||||
strategy_params = COALESCE(strategy_params, '{}'::jsonb)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.drop_column("portfolios", "avabile_strategies")
|
||||
op.drop_column("portfolios", "active_strategy_id")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add users table
|
||||
|
||||
Revision ID: 20260403_0004
|
||||
Revises: 20260403_0003
|
||||
Create Date: 2026-04-03 02:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260403_0004"
|
||||
down_revision = "20260403_0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("username", sa.Text(), nullable=False),
|
||||
sa.Column("email", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("username", name="uq_users_username"),
|
||||
sa.UniqueConstraint("email", name="uq_users_email"),
|
||||
)
|
||||
op.create_index("ix_users_created_at", "users", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_created_at", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""add user auth fields and auth tokens
|
||||
|
||||
Revision ID: 20260403_0005
|
||||
Revises: 20260403_0004
|
||||
Create Date: 2026-04-03 03:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260403_0005"
|
||||
down_revision = "20260403_0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, "users", "permission"):
|
||||
op.add_column("users", sa.Column("permission", sa.Text(), nullable=False, server_default="read"))
|
||||
if not _column_exists(inspector, "users", "is_active"):
|
||||
op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")))
|
||||
if not _column_exists(inspector, "users", "password_hash"):
|
||||
op.add_column("users", sa.Column("password_hash", sa.Text(), nullable=True))
|
||||
if not _column_exists(inspector, "users", "last_login_at"):
|
||||
op.add_column("users", sa.Column("last_login_at", sa.TIMESTAMP(timezone=True), nullable=True))
|
||||
|
||||
if not _table_exists(inspector, "auth_tokens"):
|
||||
op.create_table(
|
||||
"auth_tokens",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("token_prefix", sa.Text(), nullable=False),
|
||||
sa.Column("token_hash", sa.Text(), nullable=False),
|
||||
sa.Column("permission", sa.Text(), nullable=False),
|
||||
sa.Column("token_type", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash", name="uq_auth_tokens_token_hash"),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_user_created"):
|
||||
op.create_index("idx_auth_tokens_user_created", "auth_tokens", ["user_id", "created_at"])
|
||||
if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_hash_active"):
|
||||
op.create_index("idx_auth_tokens_hash_active", "auth_tokens", ["token_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "auth_tokens"):
|
||||
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_hash_active"):
|
||||
op.drop_index("idx_auth_tokens_hash_active", table_name="auth_tokens")
|
||||
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_user_created"):
|
||||
op.drop_index("idx_auth_tokens_user_created", table_name="auth_tokens")
|
||||
op.drop_table("auth_tokens")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "users", "last_login_at"):
|
||||
op.drop_column("users", "last_login_at")
|
||||
if _column_exists(inspector, "users", "password_hash"):
|
||||
op.drop_column("users", "password_hash")
|
||||
if _column_exists(inspector, "users", "is_active"):
|
||||
op.drop_column("users", "is_active")
|
||||
if _column_exists(inspector, "users", "permission"):
|
||||
op.drop_column("users", "permission")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""add bot principals and bot token linkage
|
||||
|
||||
Revision ID: 20260403_0006
|
||||
Revises: 20260403_0005
|
||||
Create Date: 2026-04-03 13:30:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260403_0006"
|
||||
down_revision = "20260403_0005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "bot_principals"):
|
||||
op.create_table(
|
||||
"bot_principals",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("permission", sa.Text(), nullable=False, server_default="read"),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("deactivated_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name", name="uq_bot_principals_name"),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _table_exists(inspector, "auth_tokens") and not _column_exists(inspector, "auth_tokens", "bot_principal_id"):
|
||||
op.add_column("auth_tokens", sa.Column("bot_principal_id", sa.Text(), nullable=True))
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _table_exists(inspector, "auth_tokens") and not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
|
||||
op.create_index("idx_auth_tokens_bot_created", "auth_tokens", ["bot_principal_id", "created_at"])
|
||||
|
||||
if _table_exists(inspector, "bot_principals") and not _index_exists(inspector, "bot_principals", "idx_bot_principals_active_created"):
|
||||
op.create_index("idx_bot_principals_active_created", "bot_principals", ["is_active", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "auth_tokens") and _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
|
||||
op.drop_index("idx_auth_tokens_bot_created", table_name="auth_tokens")
|
||||
|
||||
if _table_exists(inspector, "auth_tokens") and _column_exists(inspector, "auth_tokens", "bot_principal_id"):
|
||||
op.drop_column("auth_tokens", "bot_principal_id")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _table_exists(inspector, "bot_principals") and _index_exists(inspector, "bot_principals", "idx_bot_principals_active_created"):
|
||||
op.drop_index("idx_bot_principals_active_created", table_name="bot_principals")
|
||||
|
||||
if _table_exists(inspector, "bot_principals"):
|
||||
op.drop_table("bot_principals")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""remove bot owner link and enforce bot permission
|
||||
|
||||
Revision ID: 20260403_0007
|
||||
Revises: 20260403_0006
|
||||
Create Date: 2026-04-03 14:20:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260403_0007"
|
||||
down_revision = "20260403_0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "bot_principals"):
|
||||
return
|
||||
|
||||
if not _column_exists(inspector, "bot_principals", "permission"):
|
||||
op.add_column("bot_principals", sa.Column("permission", sa.Text(), nullable=True))
|
||||
|
||||
op.execute("UPDATE bot_principals SET permission = 'read' WHERE permission IS NULL")
|
||||
op.alter_column("bot_principals", "permission", existing_type=sa.Text(), nullable=False)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "bot_principals", "owner_user_id"):
|
||||
op.drop_column("bot_principals", "owner_user_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "bot_principals"):
|
||||
return
|
||||
|
||||
if not _column_exists(inspector, "bot_principals", "owner_user_id"):
|
||||
op.add_column("bot_principals", sa.Column("owner_user_id", sa.Text(), nullable=True))
|
||||
|
||||
op.execute("UPDATE bot_principals SET owner_user_id = 'legacy-owner' WHERE owner_user_id IS NULL")
|
||||
op.alter_column("bot_principals", "owner_user_id", existing_type=sa.Text(), nullable=False)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "bot_principals", "permission"):
|
||||
op.drop_column("bot_principals", "permission")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add portfolio name column
|
||||
|
||||
Revision ID: 20260403_0008
|
||||
Revises: 20260403_0007
|
||||
Create Date: 2026-04-03 14:45:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260403_0008"
|
||||
down_revision = "20260403_0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolios"):
|
||||
return
|
||||
|
||||
if not _column_exists(inspector, "portfolios", "name"):
|
||||
op.add_column("portfolios", sa.Column("name", sa.Text(), nullable=True))
|
||||
|
||||
op.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
|
||||
op.alter_column("portfolios", "name", existing_type=sa.Text(), nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolios"):
|
||||
return
|
||||
|
||||
if _column_exists(inspector, "portfolios", "name"):
|
||||
op.drop_column("portfolios", "name")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""portfolio_comments table
|
||||
|
||||
Revision ID: 20260410_0009
|
||||
Revises: 20260403_0008
|
||||
Create Date: 2026-04-10 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260410_0009"
|
||||
down_revision = "20260403_0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# `ensure_schema()` can create this table ahead of Alembic on already-running
|
||||
# environments, so this migration must be safe to re-apply.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_comments (
|
||||
id TEXT NOT NULL,
|
||||
portfolio_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolio_comments_portfolio_created
|
||||
ON portfolio_comments (portfolio_id, created_at DESC)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS idx_portfolio_comments_portfolio_created")
|
||||
op.execute("DROP TABLE IF EXISTS portfolio_comments")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""add portfolio owner user id
|
||||
|
||||
Revision ID: 20260413_0010
|
||||
Revises: 20260410_0009
|
||||
Create Date: 2026-04-13 01:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260413_0010"
|
||||
down_revision = "20260410_0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
|
||||
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, "portfolios", "owner_user_id"):
|
||||
op.add_column("portfolios", sa.Column("owner_user_id", sa.Text(), nullable=True))
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
WITH fallback_owner AS (
|
||||
SELECT id
|
||||
FROM users
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE portfolios p
|
||||
SET owner_user_id = f.id
|
||||
FROM fallback_owner f
|
||||
WHERE p.owner_user_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
unresolved = bind.execute(sa.text("SELECT COUNT(*) FROM portfolios WHERE owner_user_id IS NULL")).scalar()
|
||||
if unresolved and int(unresolved) > 0:
|
||||
raise RuntimeError("owner_user_id backfill failed: users 테이블에 fallback 소유주가 필요합니다")
|
||||
|
||||
op.alter_column("portfolios", "owner_user_id", existing_type=sa.Text(), nullable=False)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _index_exists(inspector, "portfolios", "idx_portfolios_owner_updated"):
|
||||
op.create_index(
|
||||
"idx_portfolios_owner_updated",
|
||||
"portfolios",
|
||||
["owner_user_id", sa.text("updated_at DESC")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _fk_exists(inspector, "portfolios", "fk_portfolios_owner_user"):
|
||||
op.create_foreign_key(
|
||||
"fk_portfolios_owner_user",
|
||||
"portfolios",
|
||||
"users",
|
||||
["owner_user_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _fk_exists(inspector, "portfolios", "fk_portfolios_owner_user"):
|
||||
op.drop_constraint("fk_portfolios_owner_user", "portfolios", type_="foreignkey")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _index_exists(inspector, "portfolios", "idx_portfolios_owner_updated"):
|
||||
op.drop_index("idx_portfolios_owner_updated", table_name="portfolios")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "portfolios", "owner_user_id"):
|
||||
op.drop_column("portfolios", "owner_user_id")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""restore bot principal owner user id
|
||||
|
||||
Revision ID: 20260414_0011
|
||||
Revises: 20260413_0010
|
||||
Create Date: 2026-04-14 10:30:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260414_0011"
|
||||
down_revision = "20260413_0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
|
||||
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, "bot_principals", "owner_user_id"):
|
||||
op.add_column("bot_principals", sa.Column("owner_user_id", sa.Text(), nullable=True))
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
WITH fallback_owner AS (
|
||||
SELECT id
|
||||
FROM users
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE bot_principals b
|
||||
SET owner_user_id = f.id
|
||||
FROM fallback_owner f
|
||||
WHERE b.owner_user_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
unresolved = bind.execute(sa.text("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")).scalar()
|
||||
if unresolved and int(unresolved) > 0:
|
||||
raise RuntimeError("bot_principals.owner_user_id backfill failed: users 테이블에 fallback 소유주가 필요합니다")
|
||||
|
||||
op.alter_column("bot_principals", "owner_user_id", existing_type=sa.Text(), nullable=False)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"):
|
||||
op.create_index(
|
||||
"idx_bot_principals_owner_active_created",
|
||||
"bot_principals",
|
||||
["owner_user_id", "is_active", sa.text("created_at DESC")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"):
|
||||
op.create_foreign_key(
|
||||
"fk_bot_principals_owner_user",
|
||||
"bot_principals",
|
||||
"users",
|
||||
["owner_user_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"):
|
||||
op.drop_constraint("fk_bot_principals_owner_user", "bot_principals", type_="foreignkey")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"):
|
||||
op.drop_index("idx_bot_principals_owner_active_created", table_name="bot_principals")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "bot_principals", "owner_user_id"):
|
||||
op.drop_column("bot_principals", "owner_user_id")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""add portfolio tags table
|
||||
|
||||
Revision ID: 20260414_0012
|
||||
Revises: 20260414_0011
|
||||
Create Date: 2026-04-14 16:10:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260414_0012"
|
||||
down_revision = "20260414_0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
|
||||
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
op.create_table(
|
||||
"portfolio_tags",
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("tag_value", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"):
|
||||
op.create_foreign_key(
|
||||
"fk_portfolio_tags_portfolio",
|
||||
"portfolio_tags",
|
||||
"portfolios",
|
||||
["portfolio_id"],
|
||||
["portfolio_id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"):
|
||||
op.create_index(
|
||||
"idx_portfolio_tags_tag_portfolio",
|
||||
"portfolio_tags",
|
||||
["tag_value", "portfolio_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "portfolio_tags"):
|
||||
if _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"):
|
||||
op.drop_constraint("fk_portfolio_tags_portfolio", "portfolio_tags", type_="foreignkey")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"):
|
||||
op.drop_index("idx_portfolio_tags_tag_portfolio", table_name="portfolio_tags")
|
||||
|
||||
op.drop_table("portfolio_tags")
|
||||
@@ -0,0 +1,158 @@
|
||||
"""remove bot_principals and auth_audit_logs, add service token support
|
||||
|
||||
Revision ID: 20260423_0013
|
||||
Revises: 20260414_0012
|
||||
Create Date: 2026-04-23 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260423_0013"
|
||||
down_revision = "20260414_0012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# 사전 검증: bot_principals.owner_user_id NULL 행이 있으면 중단
|
||||
if _table_exists(inspector, "bot_principals"):
|
||||
null_count = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")
|
||||
).scalar()
|
||||
if null_count and int(null_count) > 0:
|
||||
raise RuntimeError(
|
||||
f"마이그레이션 중단: bot_principals.owner_user_id NULL 행 {null_count}개 발견. "
|
||||
"owner_user_id를 먼저 보정한 후 다시 실행하세요."
|
||||
)
|
||||
|
||||
# auth_tokens.user_id 보정: bot_principal.id → bot_principal.owner_user_id
|
||||
if _column_exists(inspector, "auth_tokens", "bot_principal_id"):
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE auth_tokens t
|
||||
SET user_id = b.owner_user_id
|
||||
FROM bot_principals b
|
||||
WHERE t.bot_principal_id = b.id
|
||||
AND t.bot_principal_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# token_type 'bot' → 'service' rename
|
||||
bind.execute(
|
||||
sa.text("UPDATE auth_tokens SET token_type = 'service' WHERE token_type = 'bot'")
|
||||
)
|
||||
|
||||
# service token name unique index 생성
|
||||
inspector = inspect(bind)
|
||||
if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
|
||||
ON auth_tokens (name)
|
||||
WHERE token_type = 'service'
|
||||
"""
|
||||
)
|
||||
|
||||
# auth_tokens.bot_principal_id 컬럼 DROP
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "auth_tokens", "bot_principal_id"):
|
||||
# 기존 인덱스 제거 후 컬럼 DROP
|
||||
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
|
||||
op.drop_index("idx_auth_tokens_bot_created", table_name="auth_tokens")
|
||||
op.drop_column("auth_tokens", "bot_principal_id")
|
||||
|
||||
# auth_audit_logs 테이블 DROP
|
||||
inspector = inspect(bind)
|
||||
if _table_exists(inspector, "auth_audit_logs"):
|
||||
op.drop_table("auth_audit_logs")
|
||||
|
||||
# bot_principals 테이블 DROP (관련 인덱스·FK 포함)
|
||||
inspector = inspect(bind)
|
||||
if _table_exists(inspector, "bot_principals"):
|
||||
for idx_name in [
|
||||
"idx_bot_principals_active_created",
|
||||
"idx_bot_principals_owner_active_created",
|
||||
]:
|
||||
if _index_exists(inspector, "bot_principals", idx_name):
|
||||
op.drop_index(idx_name, table_name="bot_principals")
|
||||
op.drop_table("bot_principals")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# bot_principals 테이블 복원
|
||||
if not _table_exists(inspector, "bot_principals"):
|
||||
op.create_table(
|
||||
"bot_principals",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("owner_user_id", sa.Text(), nullable=True),
|
||||
sa.Column("permission", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("deactivated_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# auth_audit_logs 테이블 복원
|
||||
if not _table_exists(inspector, "auth_audit_logs"):
|
||||
op.create_table(
|
||||
"auth_audit_logs",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("event_type", sa.Text(), nullable=False),
|
||||
sa.Column("action", sa.Text(), nullable=False),
|
||||
sa.Column("actor_id", sa.Text(), nullable=True),
|
||||
sa.Column("bot_id", sa.Text(), nullable=False),
|
||||
sa.Column("token_id_masked", sa.Text(), nullable=True),
|
||||
sa.Column("token_ref_masked", sa.Text(), nullable=True),
|
||||
sa.Column("result", sa.Text(), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# auth_tokens.bot_principal_id 컬럼 복원
|
||||
inspector = inspect(bind)
|
||||
if not _column_exists(inspector, "auth_tokens", "bot_principal_id"):
|
||||
op.add_column("auth_tokens", sa.Column("bot_principal_id", sa.Text(), nullable=True))
|
||||
|
||||
# token_type 'service' → 'bot' 역방향 (구조 복원 목적)
|
||||
bind.execute(
|
||||
sa.text("UPDATE auth_tokens SET token_type = 'bot' WHERE token_type = 'service'")
|
||||
)
|
||||
|
||||
# service token unique index 제거
|
||||
inspector = inspect(bind)
|
||||
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
|
||||
op.drop_index("idx_auth_tokens_service_name_unique", table_name="auth_tokens")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Add v1 API schema: portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades
|
||||
|
||||
Revision ID: 20260502_0014
|
||||
Revises: 20260423_0013
|
||||
Create Date: 2026-05-02 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260502_0014"
|
||||
down_revision = "20260423_0013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# 1. portfolios.market TEXT NULLABLE
|
||||
if not _column_exists(inspector, "portfolios", "market"):
|
||||
op.add_column("portfolios", sa.Column("market", sa.Text(), nullable=True))
|
||||
|
||||
# 2. auth_tokens.runtime_id TEXT UNIQUE NULLABLE
|
||||
if not _column_exists(inspector, "auth_tokens", "runtime_id"):
|
||||
op.add_column("auth_tokens", sa.Column("runtime_id", sa.Text(), nullable=True))
|
||||
op.create_index(
|
||||
"idx_auth_tokens_runtime_id_unique",
|
||||
"auth_tokens",
|
||||
["runtime_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("runtime_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
# 3. broker_credentials 테이블 신규
|
||||
if not _table_exists(inspector, "broker_credentials"):
|
||||
op.create_table(
|
||||
"broker_credentials",
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("broker", sa.Text(), nullable=False),
|
||||
sa.Column("account_ref", sa.Text(), nullable=False),
|
||||
sa.Column("encrypted_payload", sa.Text(), nullable=False),
|
||||
sa.Column("credential_version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"last_rotated_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("user_id", "broker"),
|
||||
)
|
||||
|
||||
# 4. ledger_trades 테이블 신규
|
||||
if not _table_exists(inspector, "ledger_trades"):
|
||||
op.create_table(
|
||||
"ledger_trades",
|
||||
sa.Column(
|
||||
"ledger_entry_id",
|
||||
sa.BigInteger(),
|
||||
sa.Identity(always=False),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("asset_id", sa.Text(), nullable=False),
|
||||
sa.Column("order_id", sa.Text(), nullable=False),
|
||||
sa.Column("execution_id", sa.Text(), nullable=False),
|
||||
sa.Column("side", sa.Text(), nullable=False),
|
||||
sa.Column("quantity", sa.Numeric(20, 8), nullable=False),
|
||||
sa.Column("price", sa.Numeric(20, 8), nullable=False),
|
||||
sa.Column("fee_amount", sa.Numeric(20, 8), nullable=False, server_default="0"),
|
||||
sa.Column("tax_amount", sa.Numeric(20, 8), nullable=False, server_default="0"),
|
||||
sa.Column("executed_at", sa.TIMESTAMP(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"recorded_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("ledger_entry_id"),
|
||||
sa.UniqueConstraint("asset_id", "order_id", "execution_id", name="uq_ledger_trades_idempotency"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_ledger_trades_asset_executed",
|
||||
"ledger_trades",
|
||||
["asset_id", "executed_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "ledger_trades"):
|
||||
op.drop_table("ledger_trades")
|
||||
if _table_exists(inspector, "broker_credentials"):
|
||||
op.drop_table("broker_credentials")
|
||||
if _column_exists(inspector, "auth_tokens", "runtime_id"):
|
||||
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_runtime_id_unique"):
|
||||
op.drop_index("idx_auth_tokens_runtime_id_unique", table_name="auth_tokens")
|
||||
op.drop_column("auth_tokens", "runtime_id")
|
||||
if _column_exists(inspector, "portfolios", "market"):
|
||||
op.drop_column("portfolios", "market")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""portfolios.portfolio_id UUID 전환 및 (owner_user_id, name) UNIQUE 제약 추가
|
||||
|
||||
Revision ID: 20260511_0015
|
||||
Revises: 20260502_0014
|
||||
Create Date: 2026-05-11 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260511_0015"
|
||||
down_revision = "20260502_0014"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if table_name not in inspector.get_table_names():
|
||||
return False
|
||||
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _constraint_exists(inspector: sa.engine.Inspector, table_name: str, constraint_name: str) -> bool:
|
||||
if table_name not in inspector.get_table_names():
|
||||
return False
|
||||
return any(c["name"] == constraint_name for c in inspector.get_unique_constraints(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Step 1: Add name column IF NOT EXISTS (already present via ensure_schema in most envs)
|
||||
if not _column_exists(inspector, "portfolios", "name"):
|
||||
op.add_column("portfolios", sa.Column("name", sa.Text(), nullable=True))
|
||||
bind.execute(sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''"))
|
||||
op.alter_column("portfolios", "name", nullable=False)
|
||||
|
||||
# Step 2: Guard — skip UUID replacement if already migrated (UNIQUE constraint present)
|
||||
already_migrated = _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name")
|
||||
|
||||
if not already_migrated:
|
||||
# Collect all existing portfolio_ids
|
||||
result = bind.execute(sa.text("SELECT portfolio_id FROM portfolios"))
|
||||
old_pids = [row[0] for row in result.fetchall()]
|
||||
|
||||
for old_pid in old_pids:
|
||||
new_uuid = str(_uuid.uuid4())
|
||||
# Copy old portfolio_id to name (overwrites display name with identifier)
|
||||
bind.execute(
|
||||
sa.text("UPDATE portfolios SET name = :old_pid WHERE portfolio_id = :old_pid"),
|
||||
{"old_pid": old_pid},
|
||||
)
|
||||
# Update FK reference tables before changing portfolios PK
|
||||
bind.execute(
|
||||
sa.text("UPDATE portfolio_ledger SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
|
||||
{"new_uuid": new_uuid, "old_pid": old_pid},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("UPDATE portfolio_comments SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
|
||||
{"new_uuid": new_uuid, "old_pid": old_pid},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("UPDATE portfolio_tags SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
|
||||
{"new_uuid": new_uuid, "old_pid": old_pid},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("UPDATE ledger_trades SET asset_id = :new_uuid WHERE asset_id = :old_pid"),
|
||||
{"new_uuid": new_uuid, "old_pid": old_pid},
|
||||
)
|
||||
# Update portfolios PK last
|
||||
bind.execute(
|
||||
sa.text("UPDATE portfolios SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
|
||||
{"new_uuid": new_uuid, "old_pid": old_pid},
|
||||
)
|
||||
|
||||
# Ensure no name nulls remain before adding UNIQUE constraint
|
||||
bind.execute(
|
||||
sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
|
||||
)
|
||||
|
||||
# Step 3: Add (owner_user_id, name) UNIQUE constraint
|
||||
op.create_unique_constraint("uq_portfolios_owner_name", "portfolios", ["owner_user_id", "name"])
|
||||
|
||||
# Step 4: Verify no orphan records in FK reference tables
|
||||
for table, col in [
|
||||
("portfolio_ledger", "portfolio_id"),
|
||||
("portfolio_comments", "portfolio_id"),
|
||||
("portfolio_tags", "portfolio_id"),
|
||||
]:
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
f"SELECT COUNT(*) FROM {table}" # noqa: S608
|
||||
f" WHERE {col} NOT IN (SELECT portfolio_id FROM portfolios)"
|
||||
)
|
||||
)
|
||||
count = result.scalar()
|
||||
if count and count > 0:
|
||||
raise RuntimeError(
|
||||
f"Migration 20260511_0015: {count} orphan record(s) found in {table}.{col}"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name"):
|
||||
op.drop_constraint("uq_portfolios_owner_name", "portfolios", type_="unique")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Daemon model: daemons table, auth_tokens.daemon_id, portfolios.managed_by_daemon_id
|
||||
|
||||
Revision ID: 20260513_0016
|
||||
Revises: 20260511_0015
|
||||
Create Date: 2026-05-13 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260513_0016"
|
||||
down_revision = "20260511_0015"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Step 1: Create daemons table
|
||||
if not _table_exists(inspector, "daemons"):
|
||||
op.create_table(
|
||||
"daemons",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("owner_user_id", sa.Text(), nullable=False),
|
||||
sa.Column("daemon_slug", sa.Text(), nullable=False),
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("owner_user_id", "daemon_slug", name="uq_daemons_owner_slug"),
|
||||
)
|
||||
|
||||
# Step 2: Add auth_tokens.daemon_id (nullable FK to daemons.id, no DB-level FK constraint — soft reference)
|
||||
if not _column_exists(inspector, "auth_tokens", "daemon_id"):
|
||||
op.add_column("auth_tokens", sa.Column("daemon_id", sa.Text(), nullable=True))
|
||||
|
||||
# Step 3: Add portfolios.managed_by_daemon_id (nullable soft reference to daemons.id)
|
||||
if not _column_exists(inspector, "portfolios", "managed_by_daemon_id"):
|
||||
op.add_column("portfolios", sa.Column("managed_by_daemon_id", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _column_exists(inspector, "portfolios", "managed_by_daemon_id"):
|
||||
op.drop_column("portfolios", "managed_by_daemon_id")
|
||||
|
||||
if _column_exists(inspector, "auth_tokens", "daemon_id"):
|
||||
op.drop_column("auth_tokens", "daemon_id")
|
||||
|
||||
if _table_exists(inspector, "daemons"):
|
||||
op.drop_table("daemons")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Create assets catalog table (REQ-009)
|
||||
|
||||
Revision ID: 20260519_0017
|
||||
Revises: 20260513_0016
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0017"
|
||||
down_revision = "20260513_0016"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "assets"):
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("asset_id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("markets", sa.ARRAY(sa.Text()), nullable=False, server_default=sa.text("ARRAY[]::text[]")),
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'active'")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("asset_id"),
|
||||
sa.UniqueConstraint("symbol", name="uq_assets_symbol"),
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "assets") and not _index_exists(inspector, "assets", "idx_assets_markets_gin"):
|
||||
op.create_index(
|
||||
"idx_assets_markets_gin",
|
||||
"assets",
|
||||
["markets"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "assets") and _index_exists(inspector, "assets", "idx_assets_markets_gin"):
|
||||
op.drop_index("idx_assets_markets_gin", table_name="assets")
|
||||
|
||||
if _table_exists(inspector, "assets"):
|
||||
op.drop_table("assets")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Portfolios asset migration: data migration + asset_id FK + symbol/market DROP (REQ-011, REQ-014)
|
||||
|
||||
Revision ID: 20260519_0018
|
||||
Revises: 20260519_0017
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260519_0018"
|
||||
down_revision = "20260519_0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _constraint_exists(inspector: sa.engine.Inspector, table_name: str, constraint_name: str) -> bool:
|
||||
fk_constraints = inspector.get_foreign_keys(table_name)
|
||||
return any(fk.get("name") == constraint_name for fk in fk_constraints)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Step 1: Populate assets catalog from existing portfolios (REQ-014)
|
||||
# Only if portfolios.symbol column still exists (idempotent guard).
|
||||
# portfolios.market 컬럼은 이전 BAT-008 migration(20260502_0014) 또는
|
||||
# ensure_schema() 경로에 따라 존재하지 않을 수 있다. REQ-014 본문 "market 값이
|
||||
# NULL인 기존 행은 빈 배열로 처리"에 따라 컬럼 부재 시 markets=[]로 fallback.
|
||||
if _column_exists(inspector, "portfolios", "symbol"):
|
||||
if _column_exists(inspector, "portfolios", "market"):
|
||||
bind.execute(text("""
|
||||
INSERT INTO assets (symbol, markets)
|
||||
SELECT
|
||||
symbol,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT market) FILTER (WHERE market IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
)
|
||||
FROM portfolios
|
||||
GROUP BY symbol
|
||||
ON CONFLICT (symbol) DO NOTHING
|
||||
"""))
|
||||
else:
|
||||
bind.execute(text("""
|
||||
INSERT INTO assets (symbol, markets)
|
||||
SELECT DISTINCT symbol, ARRAY[]::text[]
|
||||
FROM portfolios
|
||||
ON CONFLICT (symbol) DO NOTHING
|
||||
"""))
|
||||
|
||||
# Step 2: Add portfolios.asset_id column (nullable, temporary)
|
||||
if not _column_exists(inspector, "portfolios", "asset_id"):
|
||||
op.add_column("portfolios", sa.Column("asset_id", sa.UUID(), nullable=True))
|
||||
|
||||
# Step 3: Back-fill asset_id from assets table
|
||||
if _column_exists(inspector, "portfolios", "symbol") and _column_exists(inspector, "portfolios", "asset_id"):
|
||||
bind.execute(text("""
|
||||
UPDATE portfolios p
|
||||
SET asset_id = a.asset_id
|
||||
FROM assets a
|
||||
WHERE a.symbol = p.symbol
|
||||
AND p.asset_id IS NULL
|
||||
"""))
|
||||
|
||||
# Step 4: Set NOT NULL constraint on asset_id
|
||||
if _column_exists(inspector, "portfolios", "asset_id"):
|
||||
op.alter_column("portfolios", "asset_id", nullable=False)
|
||||
|
||||
# Step 5: Add FK constraint (if not already present)
|
||||
if not _constraint_exists(inspector, "portfolios", "fk_portfolios_asset_id"):
|
||||
op.create_foreign_key(
|
||||
"fk_portfolios_asset_id",
|
||||
"portfolios",
|
||||
"assets",
|
||||
["asset_id"],
|
||||
["asset_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
# Step 6: Drop legacy columns
|
||||
if _column_exists(inspector, "portfolios", "symbol"):
|
||||
op.drop_column("portfolios", "symbol")
|
||||
|
||||
if _column_exists(inspector, "portfolios", "market"):
|
||||
op.drop_column("portfolios", "market")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Restore symbol and market columns
|
||||
if not _column_exists(inspector, "portfolios", "symbol"):
|
||||
op.add_column("portfolios", sa.Column("symbol", sa.Text(), nullable=True))
|
||||
|
||||
if not _column_exists(inspector, "portfolios", "market"):
|
||||
op.add_column("portfolios", sa.Column("market", sa.Text(), nullable=True))
|
||||
|
||||
# Back-fill symbol from assets table
|
||||
if _column_exists(inspector, "portfolios", "asset_id") and _column_exists(inspector, "portfolios", "symbol"):
|
||||
bind.execute(text("""
|
||||
UPDATE portfolios p
|
||||
SET symbol = a.symbol
|
||||
FROM assets a
|
||||
WHERE a.asset_id = p.asset_id
|
||||
"""))
|
||||
|
||||
# Remove FK constraint
|
||||
if _constraint_exists(inspector, "portfolios", "fk_portfolios_asset_id"):
|
||||
op.drop_constraint("fk_portfolios_asset_id", "portfolios", type_="foreignkey")
|
||||
|
||||
# Remove asset_id column
|
||||
if _column_exists(inspector, "portfolios", "asset_id"):
|
||||
op.drop_column("portfolios", "asset_id")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Create tags master table (REQ-001)
|
||||
|
||||
Revision ID: 20260519_0019
|
||||
Revises: 20260519_0018
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0019"
|
||||
down_revision = "20260519_0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "tags"):
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("tag_id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.Text(), nullable=False),
|
||||
sa.Column("is_admin_tag", sa.Boolean(), nullable=False, server_default=sa.text("FALSE")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("tag_id"),
|
||||
sa.UniqueConstraint("name", name="uq_tags_name"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_tags_created_by_user_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "tags"):
|
||||
op.drop_table("tags")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Add portfolios.tag_ids UUID[] column and GIN index (REQ-002, REQ-003)
|
||||
|
||||
Revision ID: 20260519_0020
|
||||
Revises: 20260519_0019
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0020"
|
||||
down_revision = "20260519_0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, "portfolios", "tag_ids"):
|
||||
op.add_column(
|
||||
"portfolios",
|
||||
sa.Column(
|
||||
"tag_ids",
|
||||
sa.ARRAY(sa.UUID()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::uuid[]"),
|
||||
),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "portfolios", "tag_ids") and not _index_exists(
|
||||
inspector, "portfolios", "idx_portfolios_tag_ids_gin"
|
||||
):
|
||||
op.create_index(
|
||||
"idx_portfolios_tag_ids_gin",
|
||||
"portfolios",
|
||||
["tag_ids"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _index_exists(inspector, "portfolios", "idx_portfolios_tag_ids_gin"):
|
||||
op.drop_index("idx_portfolios_tag_ids_gin", table_name="portfolios")
|
||||
|
||||
if _column_exists(inspector, "portfolios", "tag_ids"):
|
||||
op.drop_column("portfolios", "tag_ids")
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Migrate portfolio_tags data to tags master, fill tag_ids, drop portfolio_tags (REQ-006)
|
||||
|
||||
Revision ID: 20260519_0021
|
||||
Revises: 20260519_0020
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
|
||||
IMPORTANT: Set MIGRATION_ADMIN_ID to a valid user ID that exists in the users table
|
||||
before running this migration in a live environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260519_0021"
|
||||
down_revision = "20260519_0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Operator must confirm this user ID exists in the users table before running.
|
||||
# Override via `MIGRATION_ADMIN_ID` env var (recommended for live environments
|
||||
# with existing portfolio_tags rows). Default placeholder triggers admin check
|
||||
# failure on purpose if real data is present.
|
||||
MIGRATION_ADMIN_ID = os.environ.get("MIGRATION_ADMIN_ID", "migration-admin")
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def run_migration(connection, admin_user_id: str) -> None:
|
||||
"""Core migration logic — extracted for testability.
|
||||
|
||||
admin_user_id can be either a UUID (id) or username.
|
||||
"""
|
||||
# Try to find user by id (UUID) first, then by username
|
||||
result = connection.execute(
|
||||
text("SELECT id FROM users WHERE id = :uid OR username = :uid LIMIT 1"),
|
||||
{"uid": admin_user_id},
|
||||
)
|
||||
user_id = result.scalar()
|
||||
if not user_id:
|
||||
raise ValueError(
|
||||
f"Migration admin user not found in users table: {admin_user_id}. "
|
||||
"Set MIGRATION_ADMIN_ID to a valid user ID (UUID) or username before running."
|
||||
)
|
||||
|
||||
# Migrate distinct (lowercased) tag_values from portfolio_tags → tags master
|
||||
connection.execute(
|
||||
text("""
|
||||
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
|
||||
SELECT LOWER(tag_value), :admin_id, FALSE
|
||||
FROM portfolio_tags
|
||||
GROUP BY LOWER(tag_value)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
"""),
|
||||
{"admin_id": user_id},
|
||||
)
|
||||
|
||||
# Fill portfolios.tag_ids from the migrated tags
|
||||
connection.execute(text("""
|
||||
UPDATE portfolios p
|
||||
SET tag_ids = COALESCE((
|
||||
SELECT ARRAY_AGG(t.tag_id ORDER BY t.name)
|
||||
FROM portfolio_tags pt
|
||||
JOIN tags t ON t.name = LOWER(pt.tag_value)
|
||||
WHERE pt.portfolio_id = p.portfolio_id
|
||||
), '{}'::uuid[])
|
||||
"""))
|
||||
|
||||
# Drop portfolio_tags table (data is now in tags + portfolios.tag_ids)
|
||||
connection.execute(text("DROP TABLE IF EXISTS portfolio_tags"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
# portfolio_tags already gone — migration was already applied or skipped
|
||||
return
|
||||
|
||||
# 빈 portfolio_tags 환경에서는 admin 검증·INSERT가 의미 없으므로 테이블만 drop.
|
||||
# 데이터가 있으면 REQ-006 본문 정책에 따라 MIGRATION_ADMIN_ID 사전 검증을 강제.
|
||||
row_count = bind.execute(text("SELECT COUNT(*) FROM portfolio_tags")).scalar() or 0
|
||||
if row_count == 0:
|
||||
bind.execute(text("DROP TABLE IF EXISTS portfolio_tags"))
|
||||
return
|
||||
|
||||
run_migration(bind, MIGRATION_ADMIN_ID)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Re-create portfolio_tags table
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
op.create_table(
|
||||
"portfolio_tags",
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("tag_value", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["portfolio_id"], ["portfolios.portfolio_id"],
|
||||
name="fk_portfolio_tags_portfolio",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_portfolio_tags_tag_portfolio",
|
||||
"portfolio_tags",
|
||||
["tag_value", "portfolio_id"],
|
||||
)
|
||||
|
||||
# Restore portfolio_tags from tags + portfolios.tag_ids (best-effort reverse)
|
||||
bind.execute(text("""
|
||||
INSERT INTO portfolio_tags (portfolio_id, tag_value)
|
||||
SELECT p.portfolio_id, t.name
|
||||
FROM portfolios p
|
||||
CROSS JOIN LATERAL UNNEST(p.tag_ids) AS tag_id_val
|
||||
JOIN tags t ON t.tag_id = tag_id_val
|
||||
ON CONFLICT DO NOTHING
|
||||
"""))
|
||||
|
||||
# Clear tag_ids arrays and drop tags data added by this migration
|
||||
# (cannot fully reverse is_admin_tag=FALSE origin without original data)
|
||||
bind.execute(text("UPDATE portfolios SET tag_ids = '{}'::uuid[]"))
|
||||
@@ -0,0 +1,60 @@
|
||||
"""service_token rotate unique index 보강: revoked_at IS NULL 조건 추가 (REQ-017)
|
||||
|
||||
Revision ID: 20260520_0022
|
||||
Revises: 20260519_0021
|
||||
Create Date: 2026-05-20 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
revision = "20260520_0022"
|
||||
down_revision = "20260519_0021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Drop old index if it exists (non-partial index)
|
||||
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
|
||||
op.execute("DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique")
|
||||
|
||||
# CREATE INDEX CONCURRENTLY는 트랜잭션 밖에서 실행해야 하므로 autocommit_block 사용.
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_auth_tokens_service_name_unique
|
||||
ON auth_tokens (name)
|
||||
WHERE token_type = 'service' AND revoked_at IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
|
||||
op.execute("DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique")
|
||||
|
||||
op.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
|
||||
ON auth_tokens (name)
|
||||
WHERE token_type = 'service'
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Add assets.tag_ids UUID[] column and GIN index (REQ-002 CHG-20260521-02)
|
||||
|
||||
Revision ID: 20260521_0023
|
||||
Revises: 20260520_0022
|
||||
Create Date: 2026-05-21 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260521_0023"
|
||||
down_revision = "20260520_0022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, "assets", "tag_ids"):
|
||||
op.add_column(
|
||||
"assets",
|
||||
sa.Column(
|
||||
"tag_ids",
|
||||
sa.ARRAY(sa.UUID()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::uuid[]"),
|
||||
),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "assets", "tag_ids") and not _index_exists(
|
||||
inspector, "assets", "idx_assets_tag_ids_gin"
|
||||
):
|
||||
op.create_index(
|
||||
"idx_assets_tag_ids_gin",
|
||||
"assets",
|
||||
["tag_ids"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _index_exists(inspector, "assets", "idx_assets_tag_ids_gin"):
|
||||
op.drop_index("idx_assets_tag_ids_gin", table_name="assets")
|
||||
|
||||
if _column_exists(inspector, "assets", "tag_ids"):
|
||||
op.drop_column("assets", "tag_ids")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Rename ledger_trades.asset_id to portfolio_id for column-name semantic alignment (REQ-033)
|
||||
|
||||
Revision ID: 20260601_0024
|
||||
Revises: 20260521_0023
|
||||
Create Date: 2026-06-01 00:00:00
|
||||
|
||||
Background:
|
||||
- REQ-029 implementation stored `portfolio_id` values in the `asset_id` column of `ledger_trades`,
|
||||
causing schema readers to misinterpret the column as an `assets.asset_id` reference.
|
||||
- dcy-005 FR-1 + REQ-033 mandates renaming the column to align name and meaning.
|
||||
- No backfill required: existing values are already `portfolio_id` semantics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260601_0024"
|
||||
down_revision = "20260521_0023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||
cols = inspector.get_columns(table_name)
|
||||
return any(c["name"] == column_name for c in cols)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not inspector.has_table("ledger_trades"):
|
||||
return
|
||||
|
||||
if _column_exists(inspector, "ledger_trades", "asset_id") and not _column_exists(
|
||||
inspector, "ledger_trades", "portfolio_id"
|
||||
):
|
||||
op.execute(text("ALTER TABLE ledger_trades RENAME COLUMN asset_id TO portfolio_id"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not inspector.has_table("ledger_trades"):
|
||||
return
|
||||
|
||||
if _column_exists(inspector, "ledger_trades", "portfolio_id") and not _column_exists(
|
||||
inspector, "ledger_trades", "asset_id"
|
||||
):
|
||||
op.execute(text("ALTER TABLE ledger_trades RENAME COLUMN portfolio_id TO asset_id"))
|
||||
@@ -0,0 +1,94 @@
|
||||
"""consolidate ledger trades into portfolio ledger
|
||||
|
||||
Revision ID: 20260605_0025
|
||||
Revises: 20260601_0024
|
||||
Create Date: 2026-06-05 00:25:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260605_0025"
|
||||
down_revision: str | None = "20260601_0024"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PostgreSQL identity columns are implicitly NOT NULL. ``nullable=True``
|
||||
# renders a conflicting trailing ``NULL`` clause on a real PostgreSQL DB.
|
||||
op.add_column("portfolio_ledger", sa.Column("ledger_entry_id", sa.BigInteger(), sa.Identity(always=False), nullable=False))
|
||||
op.add_column("portfolio_ledger", sa.Column("order_id", sa.Text(), nullable=True))
|
||||
op.add_column("portfolio_ledger", sa.Column("execution_id", sa.Text(), nullable=True))
|
||||
op.add_column("portfolio_ledger", sa.Column("side", sa.Text(), nullable=True))
|
||||
op.add_column("portfolio_ledger", sa.Column("executed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'portfolio_ledger' AND column_name = 'action'
|
||||
) THEN
|
||||
UPDATE portfolio_ledger SET side = lower(action) WHERE side IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
op.execute("UPDATE portfolio_ledger SET side = 'manual' WHERE side IS NULL")
|
||||
op.execute("UPDATE portfolio_ledger SET executed_at = recorded_at WHERE executed_at IS NULL")
|
||||
op.alter_column("portfolio_ledger", "side", nullable=False)
|
||||
op.alter_column("portfolio_ledger", "executed_at", nullable=False)
|
||||
|
||||
for column in (
|
||||
"price",
|
||||
"quantity",
|
||||
"fee_amount",
|
||||
"tax_amount",
|
||||
"cash_balance_after",
|
||||
"asset_quantity_after",
|
||||
"total_value_after",
|
||||
"profit_buffer_after",
|
||||
):
|
||||
op.alter_column(
|
||||
"portfolio_ledger",
|
||||
column,
|
||||
existing_type=sa.Float(),
|
||||
type_=sa.Numeric(20, 8),
|
||||
postgresql_using=f"{column}::numeric(20,8)",
|
||||
)
|
||||
|
||||
op.drop_column("portfolio_ledger", "action")
|
||||
op.create_index("idx_portfolio_ledger_portfolio_executed", "portfolio_ledger", ["portfolio_id", sa.text("executed_at DESC")])
|
||||
op.drop_table("ledger_trades")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"ledger_trades",
|
||||
sa.Column("ledger_trade_id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("order_id", sa.Text(), nullable=False),
|
||||
sa.Column("execution_id", sa.Text(), nullable=False),
|
||||
sa.Column("side", sa.Text(), nullable=False),
|
||||
sa.Column("quantity", sa.Float(), nullable=False),
|
||||
sa.Column("price", sa.Float(), nullable=False),
|
||||
sa.Column("fee_amount", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("tax_amount", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("executed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("portfolio_id", "order_id", "execution_id", name="uq_ledger_trades_idempotency"),
|
||||
)
|
||||
op.add_column("portfolio_ledger", sa.Column("action", sa.Text(), nullable=True))
|
||||
op.execute("UPDATE portfolio_ledger SET action = upper(side) WHERE action IS NULL")
|
||||
op.alter_column("portfolio_ledger", "action", nullable=False)
|
||||
op.drop_index("idx_portfolio_ledger_portfolio_executed", table_name="portfolio_ledger")
|
||||
op.drop_column("portfolio_ledger", "executed_at")
|
||||
op.drop_column("portfolio_ledger", "side")
|
||||
op.drop_column("portfolio_ledger", "execution_id")
|
||||
op.drop_column("portfolio_ledger", "order_id")
|
||||
op.drop_column("portfolio_ledger", "ledger_entry_id")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1)
|
||||
|
||||
Revision ID: 20260628_0026
|
||||
Revises: 20260605_0025
|
||||
Create Date: 2026-06-28 00:00:00
|
||||
|
||||
NOTE: 이 revision은 REQ-047 Step1 — nullable daemon_id 컬럼 추가.
|
||||
기존 데이터는 daemon_id=NULL 상태로 보존된다(백필은 REQ-048 CLI 별도 수행).
|
||||
Step2(PK 전환)는 20260628_0027_broker_credential_daemon_id_step2.py에서 수행.
|
||||
Step2는 비가역(PK 변경)이므로 사장님 결재 후 별도 적용.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260628_0026"
|
||||
down_revision = "20260605_0025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# broker_credentials에 daemon_id TEXT NULL 컬럼 추가
|
||||
# 기존 PK (user_id, broker)는 그대로 유지
|
||||
if _table_exists(inspector, "broker_credentials"):
|
||||
if not _column_exists(inspector, "broker_credentials", "daemon_id"):
|
||||
op.add_column(
|
||||
"broker_credentials",
|
||||
sa.Column("daemon_id", sa.Text(), nullable=True),
|
||||
)
|
||||
# daemon_id 인덱스 추가 (조회 성능)
|
||||
op.create_index(
|
||||
"idx_broker_credentials_daemon_id",
|
||||
"broker_credentials",
|
||||
["daemon_id"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("daemon_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "broker_credentials"):
|
||||
# 인덱스 먼저 제거
|
||||
existing_indexes = [idx["name"] for idx in inspector.get_indexes("broker_credentials")]
|
||||
if "idx_broker_credentials_daemon_id" in existing_indexes:
|
||||
op.drop_index("idx_broker_credentials_daemon_id", table_name="broker_credentials")
|
||||
|
||||
if _column_exists(inspector, "broker_credentials", "daemon_id"):
|
||||
op.drop_column("broker_credentials", "daemon_id")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2)
|
||||
|
||||
Revision ID: 20260628_0027
|
||||
Revises: 20260628_0026
|
||||
Create Date: 2026-06-28 00:00:00
|
||||
|
||||
CRITICAL: 이 revision은 비가역 PK 변경을 포함한다.
|
||||
- 실 운영 DB 적용 전 사장님 결재 필수 (dcy-010 R-1, 외부 비가역 작업 게이트)
|
||||
- REQ-048 backfill-daemon-id CLI로 모든 행의 daemon_id가 채워진 이후에만 적용 가능
|
||||
(NOT NULL 제약으로 인해 daemon_id=NULL 행 존재 시 upgrade 실패)
|
||||
- downgrade는 데이터 손실 위험 — 운영 환경에서 rollback 전 별도 데이터 검증 필요
|
||||
|
||||
적용 순서:
|
||||
1. 20260628_0026 (Step1) — daemon_id NULL 컬럼 추가
|
||||
2. pfhub credential backfill-daemon-id (REQ-048 CLI)
|
||||
3. 20260628_0027 (Step2, 이 파일) — PK 전환 (사장님 결재 후 적용)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
revision = "20260628_0027"
|
||||
down_revision = "20260628_0026"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _constraint_exists(bind, table_name: str, constraint_name: str) -> bool:
|
||||
result = bind.execute(text(
|
||||
"""
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = :table AND constraint_name = :cname
|
||||
"""
|
||||
), {"table": table_name, "cname": constraint_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "broker_credentials"):
|
||||
return
|
||||
|
||||
# 사전 검증: daemon_id IS NULL인 행이 있으면 upgrade 거부
|
||||
result = bind.execute(text(
|
||||
"SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL"
|
||||
))
|
||||
null_count = result.scalar()
|
||||
if null_count and null_count > 0:
|
||||
raise RuntimeError(
|
||||
f"broker_credentials에 daemon_id IS NULL인 행이 {null_count}건 있습니다. "
|
||||
"REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요."
|
||||
)
|
||||
|
||||
# Step 1: daemon_id NOT NULL 제약 추가
|
||||
if _column_exists(inspector, "broker_credentials", "daemon_id"):
|
||||
op.alter_column("broker_credentials", "daemon_id", nullable=False)
|
||||
|
||||
# Step 2: 기존 PK (user_id, broker) 제거
|
||||
# PostgreSQL: PK 이름 찾기
|
||||
pk_constraints = [
|
||||
c for c in inspector.get_pk_constraint("broker_credentials").get("constrained_columns", [])
|
||||
]
|
||||
# PK 제약 이름을 직접 조회
|
||||
bind.execute(text(
|
||||
"""
|
||||
ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey
|
||||
"""
|
||||
))
|
||||
|
||||
# Step 3: 새 PK (daemon_id, broker) 추가
|
||||
op.create_primary_key(
|
||||
"broker_credentials_pkey",
|
||||
"broker_credentials",
|
||||
["daemon_id", "broker"],
|
||||
)
|
||||
|
||||
# NOTE: user_id 컬럼은 감사/이력 목적으로 유지 (삭제 금지, REQ-047 AC-4)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
WARN: 이 downgrade는 데이터 손실 위험을 수반한다.
|
||||
(daemon_id, broker) PK를 (user_id, broker)로 복원할 때,
|
||||
같은 user_id + broker 조합이 여러 daemon에 존재하면 충돌이 발생한다.
|
||||
운영 환경에서는 downgrade 전 반드시 별도 데이터 검증을 수행하라.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "broker_credentials"):
|
||||
return
|
||||
|
||||
# 안전장치: 실 운영 환경에서 downgrade 시도 경고
|
||||
# (REQ-047 AC-7: downgrade 실패 또는 명시적 경고 존재)
|
||||
result = bind.execute(text(
|
||||
"""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT user_id, broker, COUNT(*) as cnt
|
||||
FROM broker_credentials
|
||||
GROUP BY user_id, broker
|
||||
HAVING COUNT(*) > 1
|
||||
) t
|
||||
"""
|
||||
))
|
||||
conflict_count = result.scalar()
|
||||
if conflict_count and conflict_count > 0:
|
||||
raise RuntimeError(
|
||||
f"downgrade 불가: {conflict_count}개 (user_id, broker) 조합이 중복됩니다. "
|
||||
"같은 user의 여러 daemon이 동일 broker를 사용하고 있어 "
|
||||
"(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. "
|
||||
"수동으로 데이터를 정리한 후 downgrade하세요."
|
||||
)
|
||||
|
||||
# PK 전환 역방향: (daemon_id, broker) → (user_id, broker)
|
||||
bind.execute(text(
|
||||
"ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey"
|
||||
))
|
||||
|
||||
# daemon_id 컬럼을 nullable로 복원
|
||||
if _column_exists(inspector, "broker_credentials", "daemon_id"):
|
||||
op.alter_column("broker_credentials", "daemon_id", nullable=True)
|
||||
|
||||
# (user_id, broker) PK 복원
|
||||
op.create_primary_key(
|
||||
"broker_credentials_pkey",
|
||||
"broker_credentials",
|
||||
["user_id", "broker"],
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""portfolio_ledger: manual ledger metadata columns backfill-safe upgrade
|
||||
|
||||
Revision ID: 20260715_0028
|
||||
Revises: 20260628_0027
|
||||
Create Date: 2026-07-15 00:00:00
|
||||
|
||||
This migration adds the portfolio_ledger additive metadata columns introduced by
|
||||
REQ-056. It is safe for both fresh DBs and legacy DBs where ensure_schema() already
|
||||
created columns at runtime.
|
||||
|
||||
- source: TEXT NOT NULL DEFAULT 'broker'
|
||||
- target_ledger_entry_id: BIGINT NULL
|
||||
- confirmation_evidence: TEXT NULL
|
||||
- actor_user_id: TEXT NULL
|
||||
|
||||
Runtime behavior:
|
||||
- if source exists, backfill NULL -> 'broker', then enforce NOT NULL/default.
|
||||
- if source/other columns are absent, add them as idempotent ALTERs.
|
||||
- existing rows are preserved; only metadata defaults/backfill are adjusted.
|
||||
- downgrade is forward-only (columns are not removed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
revision = "20260715_0028"
|
||||
down_revision = "20260628_0027"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolio_ledger"):
|
||||
return
|
||||
|
||||
if not _column_exists(inspector, "portfolio_ledger", "source"):
|
||||
op.add_column(
|
||||
"portfolio_ledger",
|
||||
sa.Column(
|
||||
"source",
|
||||
sa.Text(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'broker'"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
# runtime-precreated 환경: source가 NULL이면 명시적으로 backfill 후 NOT NULL + 기본값 보장
|
||||
bind.execute(text("UPDATE portfolio_ledger SET source = 'broker' WHERE source IS NULL"))
|
||||
op.alter_column(
|
||||
"portfolio_ledger",
|
||||
"source",
|
||||
existing_type=sa.Text(),
|
||||
server_default=sa.text("'broker'"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if not _column_exists(inspector, "portfolio_ledger", "target_ledger_entry_id"):
|
||||
op.add_column(
|
||||
"portfolio_ledger",
|
||||
sa.Column("target_ledger_entry_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists(inspector, "portfolio_ledger", "confirmation_evidence"):
|
||||
op.add_column(
|
||||
"portfolio_ledger",
|
||||
sa.Column("confirmation_evidence", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists(inspector, "portfolio_ledger", "actor_user_id"):
|
||||
op.add_column(
|
||||
"portfolio_ledger",
|
||||
sa.Column("actor_user_id", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
# Forward-only policy: 운영 rollback은 컬럼 삭제/재적용 경로가 아니다.
|
||||
def downgrade() -> None:
|
||||
"""Forward-only migration: 컬럼 삭제 downgrade는 수행하지 않는다."""
|
||||
return
|
||||
@@ -0,0 +1,121 @@
|
||||
"""strategy_sets: active status, revision, and default strategy
|
||||
|
||||
Revision ID: 20260814_0029
|
||||
Revises: 20260715_0028
|
||||
Create Date: 2026-08-14 00:00:00
|
||||
|
||||
The migration is additive and idempotent for databases where ensure_schema()
|
||||
may already have created the columns. Existing strategy IDs and portfolio
|
||||
references are preserved. The canonical target_value/{} strategy is reused
|
||||
when present, otherwise one stable default row is inserted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260814_0029"
|
||||
down_revision = "20260715_0028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_DEFAULT_STRATEGY_ID = "00000000-0000-0000-0000-000000000029"
|
||||
_DEFAULT_NAME = "target_value"
|
||||
_DEFAULT_CONFIG = "{}"
|
||||
_DEFAULT_NICK = "target_value:bf21a9e8fbc5"
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "strategy_sets" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
if not _column_exists(inspector, "strategy_sets", "status"):
|
||||
op.add_column(
|
||||
"strategy_sets",
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'active'")),
|
||||
)
|
||||
if not _column_exists(inspector, "strategy_sets", "revision"):
|
||||
op.add_column(
|
||||
"strategy_sets",
|
||||
sa.Column("revision", sa.Integer(), nullable=False, server_default=sa.text("1")),
|
||||
)
|
||||
if not _column_exists(inspector, "strategy_sets", "updated_at"):
|
||||
op.add_column(
|
||||
"strategy_sets",
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
)
|
||||
|
||||
bind.execute(text("UPDATE strategy_sets SET status = 'active' WHERE status IS NULL"))
|
||||
bind.execute(text("UPDATE strategy_sets SET revision = 1 WHERE revision IS NULL OR revision < 1"))
|
||||
bind.execute(text("UPDATE strategy_sets SET updated_at = created_at WHERE updated_at IS NULL"))
|
||||
bind.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO strategy_sets (id, name, config, nick, status, revision)
|
||||
SELECT :id, :name, :config, :nick, 'active', 1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM strategy_sets WHERE name = :name AND config = :config
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": _DEFAULT_STRATEGY_ID,
|
||||
"name": _DEFAULT_NAME,
|
||||
"config": _DEFAULT_CONFIG,
|
||||
"nick": _DEFAULT_NICK,
|
||||
},
|
||||
)
|
||||
|
||||
# The same (name, config) row is the canonical default; ensure it is selectable.
|
||||
bind.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE strategy_sets
|
||||
SET status = 'active', updated_at = CASE WHEN status = 'active' THEN updated_at ELSE NOW() END
|
||||
WHERE name = :name AND config = :config AND status <> 'active'
|
||||
"""
|
||||
),
|
||||
{"name": _DEFAULT_NAME, "config": _DEFAULT_CONFIG},
|
||||
)
|
||||
|
||||
# PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS, so guard by catalog lookup.
|
||||
bind.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_strategy_sets_status'
|
||||
AND conrelid = 'strategy_sets'::regclass
|
||||
) THEN
|
||||
ALTER TABLE strategy_sets
|
||||
ADD CONSTRAINT ck_strategy_sets_status
|
||||
CHECK (status IN ('active', 'inactive'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_strategy_sets_revision_positive'
|
||||
AND conrelid = 'strategy_sets'::regclass
|
||||
) THEN
|
||||
ALTER TABLE strategy_sets
|
||||
ADD CONSTRAINT ck_strategy_sets_revision_positive
|
||||
CHECK (revision >= 1);
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Forward-only data migration; existing references and revisions are retained."""
|
||||
return
|
||||
Reference in New Issue
Block a user