- Date: 2026-07-17
- Status: Accepted
- Supersedes: ADR 0072's artboard-only
workflow_artboard_linkschema/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 thinfetchEntityLinks/putEntityLinkwrapper 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) filtersentity_linktosourceType = targetType = "artboard"instead of reading a dedicated table — one extraWHERE, 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_idfor 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.