Files
FabledScribe/alembic/versions/0072_design_systems.py
T
bvandeusen 03b3998585
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
feat(design-systems): the model, the parent chain, and the guard on it
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.
2026-07-30 16:54:41 -04:00

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")