ADRs
ADR 0072 — Server persistence for Workflow artboard prototype links (tavola)
  • Date: 2026-07-16
  • Status: Accepted
  • Builds on: ADR 0066 (workflow_artboard persistence), ADR 0063 (Studio packages split). Precedes the tavola/albero UI, which is still being scoped.

Context

tavola (artboard authoring + prototype-link navigation) and albero (auto-layout tree of artboards) are planned features of the new @arno/quadro canvas/frame umbrella package (see apps/web/src/dev-tools/studio/_index.md). Both need a persisted answer to "which component, on which artboard, navigates to which other artboard?" — workflow_artboard (ADR 0066) has no such concept: its tree jsonb has no link field, and there is no cross-artboard edge table at all.

Decision

A dedicated table workflow_artboard_link, project-scoped, one row per source node — a component has at most one outgoing prototype link, so (project_id, source_artboard_id, source_node_id) is the natural primary key and re-linking a node is an UPSERT (same shape as workflow_artboard's save).

Chosen over storing the link inline on the node (node.link: {targetArtboardId} inside workflow_artboard.tree): albero must build the full edge graph for every artboard in a project at once, and a flat table with one indexed query beats parsing every artboard's tree jsonb to find links scattered inside it.

Schema (packages/db/src/schema.ts)

workflow_artboard_link
  project_id          text  FK → project.id  ON DELETE CASCADE
  source_artboard_id  text  (workflow_artboard.artboard_id, not FK'd — see below)
  source_node_id      text  (LayoutNode id inside the source artboard's tree)
  target_artboard_id  text  (workflow_artboard.artboard_id, not FK'd — see below)
  created_at / updated_at  timestamptz default now()
  PRIMARY KEY (project_id, source_artboard_id, source_node_id)
  INDEX (project_id, target_artboard_id)  -- albero's reverse-edge lookup

No composite FK into workflow_artboard's (project_id, artboard_id) PK — no composite-FK precedent exists anywhere else in this schema (every FK in schema.ts is a single column into a single-column PK/unique). Introducing the first one here for two columns at once was judged not worth the asymmetry. Both ends are instead validated against workflow_artboard at write time in the API handler. Consequence: deleting an artboard does not cascade-delete its links or links pointing to it — orphaned links just fail to resolve a target at read time. Acceptable for now (tavola's UI hasn't shipped yet, so there's no user-visible dangling-link scenario to guard); revisit if orphan cleanup becomes an observed problem (candidate fix: a cascade cleanup pass in the artboard-delete handler, not a schema FK).

Migration hand-written (no DATABASE_URL available to run drizzle-kit generate in this session) — timestamp-prefixed, appends journal idx 43. Matches the exact CREATE TABLE / ADD CONSTRAINT shape drizzle-kit emits for workflow_artboard (ADR 0066), diffed by hand against schema.ts.

API (apps/api/src/workflow-artboard-link.ts, mounted post-auth in index.ts)

Same requireOwnedProject(c, projectId) gate as every other project-scoped route:

  • GET /api/v1/projects/:projectId/workflow-artboard-links{ items: [...] }
  • PUT /api/v1/workflow-artboard-links → upsert on (project_id, source_artboard_id, source_node_id); validates both artboard ids exist in-project before writing (the FK the schema doesn't have).
  • DELETE /api/v1/workflow-artboard-links?project=&source=&node= → idempotent.

Frontend

Not built yet — this ADR covers persistence only. The tavola UI (the "+" affordance on a component's top-right corner, the link-target picker dialog, plain-click-to-navigate with a back-stack, real :hover on canvas) and albero (the auto-layout tree view, opened from an icon on the main artboard's outside top-right corner) are scoped in ongoing design conversation, not yet in a state file.

Consequences

  • albero's edge graph is one query: SELECT * FROM workflow_artboard_link WHERE project_id = $1, joined in memory against the artboard list already fetched by useWorkflowArtboards.
  • A node can have at most one outgoing link (the PK enforces it) — matches the described UX (one "+" per component, picking a new target replaces the old one, not adds a second).
  • No incoming-link cardinality limit — multiple components (even across different source artboards) may point at the same target artboard, which is the normal case (a "back to home" button on several screens).

Alternatives considered

  • Inline node.link field in the tree jsonb — rejected, see Decision.
  • Composite FK into workflow_artboard — rejected, see Decision (schema consistency over referential-integrity strictness at the DB layer).
  • Reusing the legacy ReactFlow WorkflowEdge model (lib/workflow-store.ts, already has an instanceId/eventId/action: "navigate" shape) — rejected: that model is attached to the dead-in-main-tab abstract ScreenNode graph (workflow-canvas.tsx, alive only for /share), not to the live workflow_artboard composition-artboard data model tavola builds on.