Work / Babul / Wiki / Decisions
2026-10-21-schema-0024
Decisionsupersededverified 2026-10-21
DECISION.2026-10-21.SCHEMA-0024The schema for components made like Figma (0024)
Written 2026-10-21; approved by the owner 2026-10-21, with the three answers in Status. The one migration is written again (MVP: no data to keep). Tables go from 48 to 50: 2 added. Phase 1 of the build uses it: properties, variant values, one layer tree per component, bindings, the Icon tool.
The rule is the one every primitive already follows: a row holds only what never changes; everything that can change is a change on a draft and a ref. So properties, variant values, bindings and interactions go through drafts, Review, sync, conflicts, merge and tags with no new versioning rules. Comes from 2026-10-21-components-like-figma.
Decision
1. A component's properties: ComponentProperty
- The row is only the component and the type. A type never changes; a property of another type is a new property.
- Changes (new target kind
COMPONENT_PROPERTY):name,position(its place in the list),default,options(a Variant property's values in order; Interaction states' subset of hover · focus · pressed · disabled),preferred(an Instance swap's preferred icons or components, in order; what a Slot may hold). - State and Size are not rows. They stay
Variant.stateandVariant.size, on every component. - Interaction states is a property of its own type, because Button draws Default · Hover · Pressed · Disabled as cells. Get code writes it as CSS.
- The code name (
showTotal) is derived from the name, not stored.
enum PropertyType {
VARIANT
BOOLEAN
TEXT
INSTANCE_SWAP
SLOT
INTERACTION_STATES
}
model ComponentProperty {
id String @id @default(uuid(7)) @db.Uuid
clientId String @db.Uuid
productId String @db.Uuid
componentId String @db.Uuid
type PropertyType
createdAt DateTime @default(now()) @db.Timestamptz(3)
product Product @relation(fields: [productId, clientId], references: [id, clientId], onDelete: Cascade)
component Component @relation(fields: [componentId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
@@unique([id, productId, clientId])
@@index([componentId])
@@index([productId])
}
2. A variant's values: a change, not a table
- A variant is State × Size × the author's property values. State and Size stay columns.
- Its values are one change on the variant: target
VARIANT, selector"", propertyvalues, a JSON object{ <propertyId>: "on" }. - The spanning rule: a property missing from
valuesis one the variant spans (Select's Empty spans Open). The app checks, when a draft is saved and when a branch is merged, that no two variants of a component claim the same cell. Variant.nameis gone (the owner, 2026-10-21): a variant's name is derived from what it is: its values ("Checked on", after its state when that is not Data), else its state ("Empty"), else, for a captured style with neither, Default for the first and Variant 2, 3… in the order they were made.variantNameandreadVariantNamesin@trunk/render.@@unique([componentId, name])went with it; capture finds a variant again by its fingerprint, as before.
3. One layer tree for the whole set: DrawnElement.componentId
- The screens draw one Layers tree per component: a layer is bound once, has "Shown: Expanded · yes", and an interaction "runs in Data · Error". A layer drawn per variant could not say any of that.
- Add
DrawnElement.componentId: a layer of the whole component, drawn in every variant.DrawnElement.variantIdis gone (the owner, 2026-10-21): a drawn element lives on one page or in one component.DrawnElement_one_hostchecks exactly one ofpageIdandcomponentId. - A variant's own value for such a layer is a
DRAWN_ELEMENTchange with selector = that variant's id (selector""is every variant), so Expanded · yes can showchevron-upwhere the rest showchevron-down. - New
DRAWN_ELEMENTproperties:shown(which variants show it, by State and property values),icon(an ICON's asset),props(a nested instance's property values, JSON). - Which edits are a variant's own (the build's rule, Figma's: each variant is its own): in a set of more than one variant, a layer moved, resized, retyped or restyled in one variant is that variant's own value. What the layer is (where it sits in the tree, its name, lock, auto layout, Shown and bindings) is the layer's, in every variant. With one variant, every edit is every variant's.
SHARED_IN_SETinconstants/component-set.ts.
4. Binding a layer to a property
A change on the layer, naming the property's id:
- Drawn layer:
DRAWN_ELEMENT, propertybind.text,bind.visible,bind.instanceorbind.content. - Captured element:
COMPONENT, selector = the element's selector in every variant's markup, the same four properties. A captured Button's label is bound once, not once per variant. - An instance on a page sets its property values with
propson thePAGE_ELEMENTor theDRAWN_ELEMENT.
5. The Icon tool: DrawnKind gains ICON
An icon or a logo placed from Styles › Icons. Its asset is the change icon (an Asset id), so an Instance swap can change it.
6. Interactions: Interaction
- The row says where it is, which never changes: on a component or on a page (an instance's Show component in Pages), and on a drawn layer, a captured element (selector), or the whole variant (neither).
- Changes (new target kind
INTERACTION):
| Property | Value |
|---|---|
trigger |
CLICK · HOVER · PRESS · KEY · DELAY · PART_EVENT |
key |
for KEY: Enter, Escape, ArrowDown, Mod+K |
delay |
for DELAY: milliseconds |
event |
for PART_EVENT: { "part": <drawn element id of the nested instance>, "event": "onChange" } |
runsIn |
the variants it runs in, by State and property values: { "state": ["DATA", "ERROR"], <propertyId>: ["on"] }; unset is all |
actions |
the ordered list, below |
[
{ "kind": "CHANGE_TO", "target": "PARENT", "values": { "<propertyId>": "old", "state": "keep" } },
{ "kind": "SET_PROPERTY", "target": "SELF", "property": "<propertyId>", "value": "on" },
{ "kind": "SET_PROPERTY", "target": "SELF", "property": "<propertyId>", "toggle": true },
{ "kind": "SET_PROPERTY", "target": "SELF", "property": "<propertyId>", "step": "next" },
{ "kind": "SHOW_COMPONENT", "component": "<componentId>", "values": { "<propertyId>": "success" } },
{ "kind": "CLOSE_COMPONENT" },
{ "kind": "EMIT", "event": "onChange", "with": "<propertyId>" }
]
targetisSELForPARENT(a nested instance changes its group).stepcycles a Variant property's options (the Date picker's month, the Table's sort).CLOSE_COMPONENTwith no component closes itself. Events are derived from the EMIT actions; Get code writes each as a handler prop.- On a page the actions are SHOW_COMPONENT and CLOSE_COMPONENT only. Open page is not an action: it stays the journey link (
JourneyEdge, with itsLinkAction) on the instance's element. - "Radio's own On click is replaced" is derived: an interaction on a nested instance with the same trigger replaces the part's own.
- Triggers and actions are checked by
@trunk/dbcode when written, not by a database enum, so a trigger the kit needs later (typing, blur, drag) needs no migration.
model Interaction {
id String @id @default(uuid(7)) @db.Uuid
clientId String @db.Uuid
productId String @db.Uuid
componentId String? @db.Uuid
pageId String? @db.Uuid
drawnElementId String? @db.Uuid
selector String @default("")
createdAt DateTime @default(now()) @db.Timestamptz(3)
product Product @relation(fields: [productId, clientId], references: [id, clientId], onDelete: Cascade)
component Component? @relation(fields: [componentId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
page Page? @relation(fields: [pageId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
drawnElement DrawnElement? @relation(fields: [drawnElementId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
@@unique([id, productId, clientId])
@@index([componentId])
@@index([pageId])
@@index([drawnElementId])
@@index([productId])
}
7. Where a component came from: Component.kitKey, Component.importSystem
originstaysPrimitiveOrigin. Taken from the kit isCREATEDwithkitKey(trunk/select); imported isIMPORTEDwithimportSystem(shadcn,mui,figma-file…); made (Blank, Duplicate) isCREATEDwith neither; captured isCAPTURED.- Changes on
COMPONENT:kitVersion(the release it was taken at or last updated to,1.0.0) anddetached(true). Detach, an update and Reset to kit are edits in the draft, so they can be reviewed and undone. Reset to kit setsdetachedback and writes the kit's values over the component's own. - A duplicate of a kit component keeps
kitKeyand starts withdetachedset. - The kit version in use on the Kit screen is derived: the oldest
kitVersionamong the attached kit components. The update offer is computed: attached kit components whosekitVersionis behind the release in code. - Kit releases are code, not a table: one version number for the whole kit.
8. A kit update can conflict: Conflict.kitVersion
- A Trunk Kit update writes its changes into the draft. Where the person changed the same property since the kit version they are at, it is a conflict, settled the usual way on Review.
- Change
Conflict.mainCommitIdto optional, and addConflict.kitVersion: a conflict is raised by main's commit or by a kit release, never both.
9. Importing a kit: ImportKind.COMPONENTS, Import.fileKey, Import.tokenMap
ImportKindgainsCOMPONENTS.fileKey: the uploaded file (a Figma library file, a Storybook build). A Figma library is read from the file; no Figma token.tokenMap: their token → the product's token id, as shown before the import. What it painted is in the components' own changes, which everyone reads; the import row is the importer's, like their draft.
10. TargetKind gains COMPONENT_PROPERTY and INTERACTION
The change trigger's target_in_product knows both, so a change can point only at a row of the same product and company.
Impact
Row-level security, checks and triggers
- RLS:
ComponentPropertyandInteractionget the company policy and the product-access policy, like every product table. - Also fixed:
DrawnElementandStyleSectionhad the company policy but not the product-access one, so a member who may not open a Chosen product could read their rows. Both now have it. - Checks:
DrawnElement_one_host: exactly one ofpageId,componentId.Component_kit_key:kitKeyonly whenCREATED, astrunk/<kebab-case>.Component_import_system:importSystemset exactly whenIMPORTED, in kebab case.Interaction_one_host: exactly one ofcomponentId,pageId.Interaction_one_layer: not both a drawn layer and a selector.Conflict_one_cause: exactly one ofmainCommitId,kitVersion;Conflict_kit_version:x.y.z.
- Triggers:
ComponentPropertyandInteractionare never updated (row_never_changed).Interaction_layer_here: an interaction's drawn layer is drawn on the same component or on the same page.- Every new key carries
productIdandclientId, so no new same-company trigger is needed.
- Left to the app: at most one Interaction states property per component, no two variants in one cell, unique property names in a component. A row is shared by every ref, and a ref sees only the rows its changes make visible, so the database cannot check these per ref.
How it maps to the screens
| Screen | Reads |
|---|---|
| Component sets (13) | Variants by state and values; spans; Interaction rows on the canvas as arrows and bolts |
| Properties, New property | ComponentProperty and its changes; bindings in Layers |
| Interactions | Interaction: On (layer), Runs in (runsIn), Trigger, Then (actions) |
| Tools, Icon in Pages | DrawnKind.ICON, icon |
| Start from › From the kit | A CREATE of the component, its variants, properties, layers and interactions, with kitKey and kitVersion |
| Kit, Kit update | kitKey, kitVersion, detached; kit releases in code; Conflict.kitVersion |
| Import a kit | Import (COMPONENTS, fileKey, tokenMap); Component.importSystem |
| Playground, page Preview | Properties, values and interactions, played |
| Get code props | Properties as props; EMIT actions as handlers |
What it does not do
- No table for kit releases, events or interaction actions: releases are code, events are derived, actions are one ordered change.
- No change to how captured variants are found: State and Size stay what capture and Verify use. Captured variants are not turned into properties.
- No animated transitions between variants.
- No interaction, kit or import code in phase 1 of the build: those tables and columns are read from phase 2 on.
Why
Every choice keeps one rule: what can change is a change, so it is versioned like everything else. Against the first sketch in 2026-10-21-components-like-figma, that rejects:
VariantPropertyValueas a table (the first sketch): a captured Button's variants get their Type after capture, and a row cannot change on one branch and not another. A change can.- Name, order and code name on
ComponentProperty: renaming or reordering a property is an edit, so it is a change. The code name is derived. InteractionActionrows: an action's arguments change, so they would be rows that change. One ordered list is one change, and reordering is one change.KITinPrimitiveOrigin: it would apply to tokens and icons too.kitKeysays it.- A
sourceenum on Component with four values:originpluskitKeyandimportSystemalready says each one, once. kitVersionas a column: an update moves it, through the draft.- Show component as a page-element property: one
Interactionmodel for components and pages, so Preview plays both the same way.
Status
Approved by the owner, 2026-10-21, with these answers:
- The set's layer tree: yes,
DrawnElement.variantIdgoes. One layer tree per component; a variant's own value for a layer is aDRAWN_ELEMENTchange keyed by the variant (selector = its id). Done in the migration. - The kit's extra triggers (typing, blur, drag, release, right-click, paste) are in 1.0.0. They need no migration: triggers are checked by
@trunk/dbcode when written (phase 2). Variant.namestays until the build derives it from the values, then goes. The phase 1 build derives it, and the column is gone.
Commands run
prisma validate, prisma generate, pnpm --filter @trunk/db test:reset (trunk_test rebuilt: 50 tables), pnpm typecheck, pnpm lint, the tests, pnpm check:designs. The dev database was not touched: the owner runs pnpm db:reset.
Related
- 2026-10-21-components-like-figma · trunk-kit · 2026-10-17-drawn-elements · 2026-10-18-schema-0020 · tenant-isolation · branch-verify-merge
Sources
- The owner's approval of the 0024 screens and answers, 2026-10-21.
packages/db/prisma/schema.prisma,packages/db/prisma/migrations/20261018120000_init/migration.sql