"""source_labels: per-source name, label and tags (phase 191, task 01) Revision ID: 0030 Revises: 0029 Create Date: 2026-10-18 ``TODO.md`` L4: "Sources need a name, a label, tag(s) so you can focus the model when prompting if you have a project name or a standard name you reference things by." This revision carries the storage half (phase 191 D1) — the matching rule (D2), the boost (D3), the API (D5) and the UI (D6) are the later tasks. * ``source_labels`` — one row per SOURCE the owner has named: * ``source`` — VARCHAR(120) NOT NULL, PRIMARY KEY. The source name EXACTLY as ``documents.source`` carries it (the derived basename — the clone dir / local dir / upload file stem): the join key to the documents and to the ``git_sources`` row. The PK is the uniqueness guard (one label row per source — the ``app_settings`` 0028 reasoning). **An ALIAS key only (D1):** a label row never renames a document, a ``source/path`` locator, a citation or the KB tree. * ``display_name`` — TEXT nullable (the owner's name for the source, "the git server"). Plain TEXT: the ≤ 120-char trimmed limit is enforced at the API boundary (the phase-121 ``git_sources.branch`` pattern — plain column, boundary validation). NULL = no name. * ``label`` — TEXT nullable (the short one-line description / category, ≤ 200 trimmed, same boundary rule). NULL = no label. * ``tags`` — JSONB NOT NULL with server default ``'[]'`` (the phase-89 ``git_sources.ignore_paths`` shape): the focus tokens, each ≤ 32 chars, ≤ 10 entries, guarded at the API boundary (``app.rag.source_labels.normalize_tags``). * ``updated_at`` — TIMESTAMPTZ NOT NULL, ``now()`` server default (the ``app_settings`` / ``folder_summaries`` house style). NO backfill (D1): the table is created EMPTY — a source without a row has no name/label/tags and behaves exactly as pre-phase, so "no label row → every surface byte-identical" holds without a data step. No index beyond the PK: every read is a PK lookup or a whole-table walk of a handful of rows (the ``app_settings`` "never a query surface" note). One additive, fully reversible migration (A13). """ from __future__ import annotations import sqlalchemy as sa from sqlalchemy.dialects import postgresql from alembic import op revision = "0030" down_revision = "0029" branch_labels = None depends_on = None def upgrade() -> None: # No index beyond the PK (every read is a PK lookup or the whole # handful-row table), no backfill (an empty table = every source # keeps its pre-phase "no row" behavior). op.create_table( "source_labels", sa.Column("source", sa.String(length=120), primary_key=True), sa.Column("display_name", sa.Text(), nullable=True), sa.Column("label", sa.Text(), nullable=True), sa.Column( "tags", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'"), nullable=False, ), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), ) def downgrade() -> None: # The table is the only 0030 artefact — dropping it leaves 0029's # schema byte-identical (A13, fully reversible). No owner data is # trapped: the names/labels/tags are re-enterable from the Sources # page (task 03), and nothing else was mutated by 0030. op.drop_table("source_labels")