"""drop notes.title and note_revisions.title — a note's name is its first line Revision ID: 0026 Revises: 0025 Create Date: 2026-08-22 M13 step 3. A note is a body plus optional checkable items; its NAME is the first non-empty line of that body, falling back to its first checklist item. There is no separate field to type into, and `display_title` (already persisted, already what search results and export filenames read) carries the name. ## The search vector has to be rebuilt, not just left alone `notes.search_vector` is a STORED GENERATED column whose expression names `title` (migration 0005, weight A) — Postgres will refuse to drop a column another generated column depends on, and even if it didn't, the weighting would be wrong. So it is dropped and recreated over `display_title` instead, which keeps the original intent: the note's NAME ranks above the rest of its body. Rebuilding a stored generated column re-computes every row, and the GIN index is rebuilt with it. On a personal instance that is milliseconds; it is worth knowing before running this against something large. ## What happens to existing titles Nothing preserves them, deliberately: `display_title` was already derived from the title when one was set, so every note keeps the NAME it had. What is lost is the distinction between "this note has an explicit title" and "this note's first line is its name" — which is the distinction being removed. Imports are the exception and are handled in code, not here: a Keep note's title, or one in an export taken before this, is folded in as the note's first body line rather than dropped (see `_create_imported_note`). """ from alembic import op import sqlalchemy as sa revision = "0026" down_revision = "0025" branch_labels = None depends_on = None def upgrade() -> None: # Order matters: the generated column depends on `title`, so it goes first. op.execute("DROP INDEX IF EXISTS ix_notes_search") op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector") op.drop_column("notes", "title") op.drop_column("note_revisions", "title") op.execute( """ ALTER TABLE notes ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(display_title, '')), 'A') || setweight(to_tsvector('english', coalesce(body, '')), 'B') ) STORED """ ) op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)") def downgrade() -> None: op.execute("DROP INDEX IF EXISTS ix_notes_search") op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector") # Comes back empty. The text is not gone — it is the first line of every body — # but which notes once had an explicit title is not recorded anywhere. op.add_column("notes", sa.Column("title", sa.Text(), nullable=True)) op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True)) op.execute( """ ALTER TABLE notes ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(body, '')), 'B') ) STORED """ ) op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")