Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/models/company.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class Company(db.Model):
# One company per ticker per user. Application-level guards existed on
# every creation path but duplicates still got through.
db.UniqueConstraint('user_id', 'ticker_symbol', name='uq_company_user_ticker'),
# Dashboard filter_by(user_id=..., is_in_portfolio=True)
db.Index('idx_company_user_portfolio', 'user_id', 'is_in_portfolio'),
)

# Relationships
Expand Down
8 changes: 8 additions & 0 deletions app/models/idea_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@

class IdeaPipeline(db.Model):
__tablename__ = 'idea_pipeline'
__table_args__ = (
# Dashboard + too-hard filter_by(user_id=..., status=...)
db.Index('idx_idea_pipeline_user_status', 'user_id', 'status'),
)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
Expand Down Expand Up @@ -116,6 +120,10 @@ def __repr__(self):

class KillSession(db.Model):
__tablename__ = 'kill_session'
__table_args__ = (
# Analytics filter_by(user_id=current_user.id)
db.Index('idx_kill_session_user', 'user_id'),
)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
idea_id = db.Column(db.Integer, db.ForeignKey('idea_pipeline.id'), nullable=False, index=True)
Expand Down
2 changes: 2 additions & 0 deletions app/models/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ class PortfolioPosition(db.Model):
# Ensure one position per user per company
__table_args__ = (
db.UniqueConstraint('user_id', 'company_id', name='uq_user_company_position'),
# Portfolio dashboard filter_by(user_id=..., is_active=True)
db.Index('idx_portfolio_position_user_active', 'user_id', 'is_active'),
)

# Relationships
Expand Down
12 changes: 12 additions & 0 deletions app/models/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ class ResearchProject(db.Model):
__tablename__ = 'research_project'
__table_args__ = (
db.UniqueConstraint('user_id', 'company_id', name='uq_research_project_user_company'),
# Too-hard service filter_by(user_id=..., decision='pass')
db.Index('idx_research_project_user_decision', 'user_id', 'decision'),
# Priority service filter_by(user_id=..., status='active')
db.Index('idx_research_project_user_status', 'user_id', 'status'),
)

id = db.Column(db.Integer, primary_key=True)
Expand Down Expand Up @@ -354,6 +358,10 @@ class WorkSession(db.Model):
and identify which parts of their process are most time-consuming.
"""
__tablename__ = 'work_session'
__table_args__ = (
# Time allocation filter(user_id=..., start_time >= ...)
db.Index('idx_work_session_user_start', 'user_id', 'start_time'),
)

id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('research_project.id'), nullable=False, index=True)
Expand Down Expand Up @@ -491,6 +499,10 @@ class ResearchLog(db.Model):
This is the raw data that feeds into aggregated metrics.
"""
__tablename__ = 'research_log'
__table_args__ = (
# Streak calculation filter(user_id=...) + timestamp
db.Index('idx_research_log_user_timestamp', 'user_id', 'timestamp'),
)

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
Expand Down
109 changes: 109 additions & 0 deletions migrations/versions/reconcile_schema_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# StartWithA
# Copyright (C) 2024-2026 Kiran Mathews
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""reconcile schema drift

The dev/prod databases had drifted from the models with no migration recording
the difference (issue #306). Two schema differences remained un-migrated:

1. ``uq_company_user_ticker`` on ``company (user_id, ticker_symbol)`` -- declared
in the model but missing from dev/prod, because the ``unique_company_ticker``
migration self-skips when duplicate companies exist and duplicates existed at
the time it ran. They have since been resolved, so the constraint can finally
be applied where it is still missing.

2. ``free_research_question.company_id`` -- the model declares it NOT NULL, but
the column was added nullable (``standalone_free_research``, for a backfill)
and never tightened.

The eight compound performance indexes from ``e059bebc4087`` were the third part
of the drift: they existed in every database but were never declared in the
models, so autogenerate kept wanting to DROP them. Those are fixed purely in the
models (their ``__table_args__``) -- no schema change is needed here, since the
indexes already exist -- and that is why this migration does not touch them.

This migration is written to be safe on BOTH:
- fresh databases, where ``unique_company_ticker`` already created the constraint
(no duplicates on an empty DB) -- the add is skipped as a no-op; and
- drifted dev/prod, where the constraint is absent -- it is added.

Revision ID: reconcile_schema_drift
Revises: unique_company_ticker
Create Date: 2026-07-25

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'reconcile_schema_drift'
down_revision = 'unique_company_ticker'
branch_labels = None
depends_on = None

CONSTRAINT_NAME = 'uq_company_user_ticker'


def upgrade():
conn = op.get_bind()

# --- uq_company_user_ticker ------------------------------------------
# Only add it where it is still missing (it is already present on fresh
# databases). If duplicates somehow still exist, skip rather than fail --
# a blocked deploy helps nobody, and dedupe is a deliberate, non-automatic
# step (see scripts/dedupe_companies.py).
already_present = conn.execute(sa.text(
"SELECT 1 FROM pg_constraint WHERE conname = :name"
), {"name": CONSTRAINT_NAME}).scalar()

if not already_present:
duplicates = conn.execute(sa.text("""
SELECT user_id, ticker_symbol, count(*) AS n
FROM company
GROUP BY user_id, ticker_symbol
HAVING count(*) > 1
""")).fetchall()

if duplicates:
detail = ', '.join(f'user {d.user_id}/{d.ticker_symbol} x{d.n}'
for d in duplicates[:10])
print(f'SKIPPING {CONSTRAINT_NAME}: {len(duplicates)} duplicate '
f'(user_id, ticker_symbol) group(s) exist -- {detail}. '
f'Run scripts/dedupe_companies.py to resolve them, then add a '
f'follow-up migration to apply the constraint.')
else:
op.create_unique_constraint(
CONSTRAINT_NAME, 'company', ['user_id', 'ticker_symbol']
)

# --- free_research_question.company_id NOT NULL ----------------------
# Every research question references a company; the model has always declared
# this NOT NULL. Fails loudly if any NULL rows exist -- verify (and backfill)
# before deploying to an environment that might have them.
op.alter_column(
'free_research_question', 'company_id',
existing_type=sa.Integer(), nullable=False,
)


def downgrade():
op.alter_column(
'free_research_question', 'company_id',
existing_type=sa.Integer(), nullable=True,
)
# DROP IF EXISTS: the constraint may have been a no-op add on this database.
op.execute(f'ALTER TABLE company DROP CONSTRAINT IF EXISTS {CONSTRAINT_NAME}')
Loading