ADRs
ADR 0073 — Generic entity-link connector (tavola supersedes artboard-only)
  • Date: 2026-07-17
  • Status: Accepted
  • Supersedes: ADR 0072's artboard-only workflow_artboard_link schema/API/stores. The "+" UI itself (packages/quadro/src/features/tavola/) is unchanged by this ADR — only the data model and state layer beneath it move from artboard-specific to generic.

Context

Tavola (the "+" prototype-link affordance) was built against a hardcoded artboard → artboard model: a dedicated workflow_artboard_link table, an API route that validated both ends against workflow_artboard, and three module-level singleton stores (artboard-selection.ts, artboard-navigation-history.ts, artboard-focus-request.ts) named and scoped to artboards.

Per the maintainer: tavola is meant to be a standalone connector tool — a way to link any two objects — not a feature that happens to live inside the Workflow artboard graph. "Условно сегодня к артборду, а завтра уже нет" (today it applies to an artboard, tomorrow it might not). A second sidebar tab wiring up tavola against a different pair of entity kinds (e.g. a Library component to a design token) must not require touching the artboard-specific schema/API, and critically must not share the artboard tab's singleton state — two independent connector instances silently sharing one global "selected id" / one global back-stack is a real bug, not a style nit.

Decision

Schema: entity_link (generic) replaces workflow_artboard_link

entity_link
  project_id      text  FK → project.id  ON DELETE CASCADE
  source_type     text  (free-form slug, e.g. "artboard" — not an enum)
  source_id       text
  source_node_id  text  (NOT NULL — see below)
  target_type     text
  target_id       text
  created_at / updated_at  timestamptz default now()
  PRIMARY KEY (project_id, source_type, source_id, source_node_id)
  INDEX (project_id, target_type, target_id)  -- reverse-edge lookup (albero)

source_type/target_type are free-form text, not a Postgres enum or a TS union — a new entity kind never needs a migration. source_node_id stays NOT NULL (not made nullable for entity kinds without a sub-part concept): a nullable column can't be part of a composite PK, and every entity kind so far (artboard) has a concrete "which part of the source" concept. A future kind with no natural sub-part re-uses its own id as source_node_id rather than complicating the key.

No FK from source_id/target_id into any specific entity table — there is no single one to point at by design (this is the whole point: the table doesn't know what an "artboard" is). Existence validation, if a given entity kind wants it, is opt-in future work in the API layer keyed by type, not a schema constraint. This is a continuation of ADR 0072's original position (no composite-FK precedent in this schema), now made structural rather than incidental.

Migration 20260717150000_entity_link.sql drops workflow_artboard_link outright (no data migration) — the table was added this same work session, pre-launch, no real rows to preserve.

API: apps/api/src/entity-link.ts (generic) replaces workflow-artboard-link.ts

Same requireOwnedProject gate as every project-scoped route. GET /api/v1/projects/:projectId/entity-links accepts optional sourceType/sourceId query filters; PUT/DELETE /api/v1/entity-links take the type slugs explicitly in the body/query.

State: quadro's connector-stores.ts (factories) replace three artboard singletons

packages/quadro/src/shared/connector-stores.ts exports three factories — createSelectionStore(), createNavigationHistoryStore(), createFocusRequestChannel() — extracted verbatim from the Workflow-tab-specific singletons that existed before this ADR. Each factory call produces an independent instance; nothing is shared across consumers by default.

apps/web/src/lib/artboard-selection.ts / artboard-navigation-history.ts / artboard-focus-request.ts become thin one-line instantiations (export const artboardSelection = createSelectionStore(), etc.) — the Workflow tab's OWN instance. A second consumer must call the factories again for its own instance; it must not import these three files.

Frontend client: fetchEntityLinks/putEntityLink/deleteEntityLink (generic) + a thin artboard wrapper

apps/web/src/lib/api-client.ts gains the generic three functions. fetchWorkflowArtboardLinks/putWorkflowArtboardLink/deleteWorkflowArtboardLink become thin wrappers over them with sourceType/targetType hardcoded to "artboard" — every existing caller (use-workflow-artboard-links.ts, artboards-canvas.tsx) is unchanged, zero-diff.

Consequences

  • A second tavola consumer (new sidebar tab, different entity kinds) needs: its own createSelectionStore()/createNavigationHistoryStore()/createFocusRequestChannel() instances, its own thin fetchEntityLinks/putEntityLink wrapper with its own type slugs, and its own <Tavola/>/<BackButton/> wiring (unchanged — already generic). No schema, API route, or quadro package change required.
  • albero (the planned auto-layout tree view) filters entity_link to sourceType = targetType = "artboard" instead of reading a dedicated table — one extra WHERE, same query shape as before.
  • Existence validation for a new entity kind (if ever wanted) is scoped per-type in the API layer, not a blanket schema rule — an intentionally deferred decision, not an oversight.

Alternatives considered

  • Keep workflow_artboard_link, add a parallel table per new entity pair as they appear — rejected: duplicates the whole stack (schema, API, stores) per pairing, exactly the coupling the maintainer flagged.
  • Nullable source_node_id for entity kinds without a sub-part — rejected: breaks the composite PK; re-using the entity's own id is simpler and has no real downside for the one entity kind that exists today.
  • A TS union / Postgres enum for source_type/target_type — rejected: forces a migration or a code deploy for every new entity kind, defeating the point of genericity.