CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 27s
Milestone #254 step 1 (#2286). A design system becomes a record Scribe holds rather than prose in a rulebook: a named set of tokens with an OPTIONAL parent, so a family system carries the house style and an app system carries only what it changes. Answering "what does this app alter?" is then `list its tokens` — nothing to compute. `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; the chain supplies the rest) — less schema than the rulebook shape it mirrors. Two decisions the task left open, settled here: - **Token values are JSONB keyed by mode**, not `value_light`/`value_dark` columns. The deciding argument was not flexibility, it was ambiguity: in a child system an unset mode means "inherit", in a root it means "not mode-dependent", and as columns both are NULL and the resolver cannot tell them apart. As a map, resolution is `{**parent, **child}` at every level with no special case for roots. Against it: queryability — but nothing filters tokens by value in SQL, so that buys a query no caller makes. - **`group_name` is free text, no 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, so rule #36 does not fire. The cascade lives in `services/design_cascade.py` as pure functions over a `{id: parent_id}` map, importing nothing — which is what lets both the service and `access.py` use it without a cycle, and lets a test state a whole hierarchy in one literal. Cycles are refused on WRITE by walking up from the proposed parent (the cheap direction), and survived on READ by a visited-set, because a loop from a direct DB edit must truncate rather than hang. ACL (rule #78) is deliberately asymmetric: owning a system grants write, reaching one through a project you can see grants READ ONLY. An editor on a shared project must not be able to rewrite the family system every other project in that family resolves through. Also renames `services/design_system.py` -> `design_rulebook_import.py`. It is the #251 prose extractor, whose role is already scheduled to become a one-shot importer (#2288), and leaving it one character away from the new `design_systems.py` was a trap for every later session. Rule #115 throughout: nothing seeds a system or implies a default. An install with zero design systems is ordinary, not degraded.
139 lines
6.0 KiB
Python
139 lines
6.0 KiB
Python
"""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")
|