"""design systems + tokens, and the project pointer Revision ID: 0072 Revises: 0071 Create Date: 2026-07-30 Makes the design system a first-class record instead of prose in a rulebook: a named set of tokens with an optional parent, so a family system holds the house style and an app system holds only what it changes. `design_systems.parent_id` is the whole model. It replaces both an `always_on` flag (a family system is one with no parent) and a subscription join table (a project points at ONE system, and the chain supplies the rest), which is less schema than the rulebook shape it mirrors. Two deliberate choices worth stating here rather than leaving to be re-derived: - **`design_tokens.value_by_mode` is JSONB keyed by mode**, not `value_light` + `value_dark` columns. In a child system an unset mode means "inherit"; in a root it would mean "not mode-dependent", and as columns both are NULL and indistinguishable. As a map, resolution is a dict merge at every level with no special case for roots — and a third mode (high-contrast, print) is data rather than a schema change. The cost is that a typo'd mode key is not rejected by the database. Nothing filters tokens by value in SQL, so the queryability the columns would have bought is for a query no caller makes. (Named `value_by_mode` rather than `values`, which is reserved in SQL.) - **`group_name` is free text, not a CHECK enum.** Groupings are each design system's own vocabulary; a whitelist would bake one install's kit into the schema. No CHECK is introduced anywhere in this migration. `parent_id` and `projects.design_system_id` are both ON DELETE SET NULL. Deleting a family system must orphan its children into roots that still hold their own overrides, not cascade away every app system that inherited from it; deleting a system a project points at must unstyle that project, not delete it. Downgrade drops both tables and the column. Any design system defined this way is lost — this is the migration that introduces the concept, so there is no earlier representation to fall back to. """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB revision = "0072" down_revision = "0071" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "design_systems", sa.Column("id", sa.BigInteger(), primary_key=True), sa.Column( "owner_user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ), sa.Column("title", sa.Text(), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column( "parent_id", sa.BigInteger(), sa.ForeignKey("design_systems.id", ondelete="SET NULL"), 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("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.Column("deleted_batch_id", sa.Text(), nullable=True), ) op.create_index( "ix_design_systems_owner_user_id", "design_systems", ["owner_user_id"] ) op.create_index("ix_design_systems_parent_id", "design_systems", ["parent_id"]) op.create_table( "design_tokens", sa.Column("id", sa.BigInteger(), primary_key=True), sa.Column( "design_system_id", sa.BigInteger(), sa.ForeignKey("design_systems.id", ondelete="CASCADE"), nullable=False, ), sa.Column("name", sa.Text(), nullable=False), # NOT NULL with a '{}' default: a nullable JSONB column has two empty # states (SQL NULL and JSON null) and every reader has to test for both. sa.Column( "value_by_mode", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb"), ), sa.Column("group_name", sa.Text(), nullable=True), sa.Column("purpose", sa.Text(), nullable=True), sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), 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("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.Column("deleted_batch_id", sa.Text(), nullable=True), ) op.create_index( "ix_design_tokens_design_system_id", "design_tokens", ["design_system_id"] ) # Partial unique: a name is unique among LIVE tokens in a system. Two live # rows with the same name are a duplicate definition and the cascade would # pick between them arbitrarily; a trashed row must not block reusing its # name. op.create_index( "uq_token_per_design_system", "design_tokens", ["design_system_id", "name"], unique=True, postgresql_where=sa.text("deleted_at IS NULL"), ) op.add_column( "projects", sa.Column("design_system_id", sa.BigInteger(), nullable=True) ) op.create_foreign_key( "fk_projects_design_system_id", "projects", "design_systems", ["design_system_id"], ["id"], ondelete="SET NULL", ) def downgrade() -> None: op.drop_constraint("fk_projects_design_system_id", "projects", type_="foreignkey") op.drop_column("projects", "design_system_id") op.drop_index("uq_token_per_design_system", table_name="design_tokens") op.drop_index("ix_design_tokens_design_system_id", table_name="design_tokens") op.drop_table("design_tokens") op.drop_index("ix_design_systems_parent_id", table_name="design_systems") op.drop_index("ix_design_systems_owner_user_id", table_name="design_systems") op.drop_table("design_systems")