"""pgvector: note_embeddings.embedding JSONB -> vector(384) + HNSW index

Revision ID: 0067
Revises: 0066
Create Date: 2026-06-22

Moves semantic search off the full-table Python cosine scan onto a native
pgvector column so ranking + top-k run as an indexed `ORDER BY embedding <=> :q
LIMIT k` in Postgres (see services/embeddings.semantic_search_notes).

Requires a Postgres image that bundles the `vector` extension — the stack moved
from postgres:16-alpine to pgvector/pgvector:pg16 in the same change (compose +
CI). `CREATE EXTENSION IF NOT EXISTS vector` below is the in-db half.

Embeddings are DERIVED data (regenerated from note text by
backfill_note_embeddings at startup), so this migration is free to drop any row
it can't cleanly convert: only rows whose stored JSONB array is exactly 384-dim
are carried over (guarding against stale vectors from an earlier model — the
same mixed-dim hazard _cosine_similarity defended against). Dropped rows are
re-embedded on next boot.
"""
from alembic import op


revision = "0067"
down_revision = "0066"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.execute("CREATE EXTENSION IF NOT EXISTS vector")

    # New native-vector column, populated only from cleanly-convertible rows.
    # A JSONB array like [0.1, 0.2, ...] renders to text that is exactly
    # pgvector's input literal, so (embedding::text)::vector is a direct cast.
    op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_vec vector(384)")
    op.execute(
        """
        UPDATE note_embeddings
           SET embedding_vec = (embedding::text)::vector
         WHERE jsonb_array_length(embedding) = 384
        """
    )
    # Stale-dim rows (couldn't convert) are derived data — drop and let the
    # startup backfill regenerate them at the current dimension.
    op.execute("DELETE FROM note_embeddings WHERE embedding_vec IS NULL")

    op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_vec SET NOT NULL")
    op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
    op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_vec TO embedding")

    # HNSW index for cosine distance — matches Vector.cosine_distance (`<=>`).
    op.execute(
        """
        CREATE INDEX ix_note_embeddings_embedding_hnsw
            ON note_embeddings
         USING hnsw (embedding vector_cosine_ops)
        """
    )


def downgrade() -> None:
    # Back to JSONB. pgvector renders a vector to a text literal that is a valid
    # JSON array, so the reverse cast is symmetric. The `vector` extension is
    # intentionally left installed (other objects may depend on it; dropping an
    # extension is the riskier, rarely-wanted direction).
    op.execute("DROP INDEX IF EXISTS ix_note_embeddings_embedding_hnsw")
    op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_json jsonb")
    op.execute("UPDATE note_embeddings SET embedding_json = (embedding::text)::jsonb")
    op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_json SET NOT NULL")
    op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
    op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_json TO embedding")
