Skip to content

feat(app): relations by name - #325

Open
aaaaahaaaaa wants to merge 8 commits into
feat/campaign-matcherfrom
feat/relation-model-app
Open

feat(app): relations by name#325
aaaaahaaaaa wants to merge 8 commits into
feat/campaign-matcherfrom
feat/relation-model-app

Conversation

@aaaaahaaaaa

Copy link
Copy Markdown
Contributor

Summary

Phase 4 of the relation-model refactor: the SPA now addresses component relations by their
declared name rather than by a positional slot or a kind guess. The components store,
the wizards, the asset picker, the graph and the delete previews all read the catalog's
relations map ({name: {kind, key, many, optional, on_delete}}) and speak to the API in
those terms: POST /components/ sends relations: {<name>: [{dst_id}]}, and single-edge
work goes through POST|DELETE /components/{id}/relations[/{name}/{dst_id}].

The visible consequence is that a component can declare several relations of the same kind,
and a relation can hold several bindings, without the UI having to guess which is which.

What changes in the UI

  • Relation steps by name. The definition stepper generates one picker step per declared
    relation and titles it from the relation name, so a connection relation renders a
    "Connection" step and targets a "Targets" step. The recap rows and the step icons key
    off the same names. x-fetch providers are resolved by relation name too
    (connection.accounts asks the backend for the resource bound to connection).
  • Many-valued upstream picker. An asset relation with many: true gets a multi-select
    cross-source picker instead of a single choice. The campaign matcher's campaigns
    relation lists every candidate source and binds one relation row per selection.
  • Wildcard candidates. A relation key like *.campaigns offers every source that
    declares a matching asset, rather than a single hardcoded key.
  • Graph pairing. Edges pair upstream and downstream by relation name and key list, so
    the graph draws (and deletes) the exact relation row behind an edge instead of inferring
    one. Recreating an edge by drag posts the relation name.
  • Delete preview. The confirm dialog distinguishes the two on_delete outcomes: a
    detaching relation lists what the component will be removed from and lets the delete
    proceed, a blocking one lists the referrers and disables the delete, matching the API's
    409. The preview reads on_delete alone, not optional, so a used destination
    (optional: true, on_delete: block) now previews as blocking, matching the API's refusal.
  • Repointing an upstream. Dragging a bound single-valued upstream onto a different
    asset moves it. The connection rules refuse only the binding that already exists, since
    the API deletes the row the name holds and inserts the new one under a lock. Clearing the
    edge first was never an option: a non-optional relation refuses to reach zero bindings.
  • Cross-source wiring reports failures. The source wizard binds an asset's cross-source
    legs one at a time after the save and names the ones that failed in a single error toast,
    instead of leaving a saved source whose upstreams silently never bound.
  • Missing-relation toast. Disconnecting an upstream whose relation row cannot be found
    (a stale graph view) now shows an error toast asking the user to reload, instead of
    silently doing nothing.

Wire dependency

Needs phase 2's API: the name-addressed relation routes, RelationResponse.src_kind, and
the catalog's relations map. Against an older backend the wizards cannot bind anything.

Stack

Stacked on PR #324 (phase 3, the campaign matcher), base branch feat/campaign-matcher.
Retarget as the stack merges. This is the last PR of the stack.

Verification

Static gates, from packages/interloper-app/app:

  • pnpm run lint: 0 errors (1 pre-existing vue/no-v-html warning in
    executions/ErrorDetailModal.vue).
  • pnpm exec nuxt typecheck: exit 0, no error TS.
  • Retired-name sweep: no relation-sense hits left. The surviving matches are Nuxt UI
    slots styling keys, Vue useSlots, Vue Flow 'source' | 'target' handle types,
    component kinds (fetchAll(['destination'])), warning categories and prose. Four
    identifiers still carried the retired "slot" vocabulary in relation positions
    (resourceSlotSteps, resourceSlotIcon, ResourceStep.slotName, SchemaForm.fetchSlot)
    and were renamed after relations, one definition per file across four files, plus three
    more files updated at their call sites.

Headless walk on a throwaway database (interloper_verify4, created, migrated, seeded and
dropped), instance on port 3100:

step outcome
a. Connection wizard facebook_ads_connection, tiktok_ads_connection and adservice_connection created with dummy credentials. The wizard performs no vendor call on save (its "Test connection" is an explicit button), so nothing had to go through the API.
b. Destination wizard csv_destination created with a base_path under the scratch directory.
c. Source wizard The facebook_ads wizard shows a "Connection" step and a "Destination" step, both titled from the relation names connection and destinations, and the details recap lists them by name. Its save is blocked offline: account_id is a required x-fetch field whose provider is connection.accounts, which cannot resolve against dummy credentials, so the combobox and the Create button stay disabled. facebook_ads and tiktok_ads were therefore created through the API with the same relation payload. A full wizard save was done with adservice (a connector with no vendor-fetch required field), whose POST body is relations: {connection: [{dst_id}], destinations: [{dst_id}]}. Every source row shows the CSV Verify Dest badge and GET /components/{id} returns both relations by name.
d. Matcher wizard The campaign_matcher asset step shows campaign_matches with 1 dep and a Campaigns row carrying a many-valued "Select sources…" picker; the picker lists both connectors. Selecting both posts two POST /components/{asset}/relations {"name": "campaigns", ...} calls, and GET /components/relations?src_kind=asset&dst_kind=asset lists two rows named campaigns.
e. Graph The matcher asset shows two incoming edges. "Disconnect upstream" removes as many legs as the relation allows: campaigns is non-optional, so the API refuses the last one with 'campaign_matches'.campaigns is non-optional and cannot be emptied; repoint it or remove the dependent component instead, which the node now surfaces as an error toast. Recreating the removed leg by dragging the matcher's target handle onto the upstream posts {"name": "campaigns", "dst_id": ...} and the edge returns.
f. Delete preview The walk observed the pre-ruling behaviour here: deleting the CSV destination listed all four referring sources under "It will also be removed from" and succeeded, because the store detached any optional relation. on_delete alone governs deletion now, and the preview mirrors that rule, so a destination in use previews as blocking; the path is covered by typecheck plus the store test test_bound_destination_blocks_delete_and_names_referrer in packages/interloper-db/tests/store/test_components.py, which asserts a used destination is refused and names its referrer. Deleting the facebook source is blocked: the dialog reads "Still in use", then "rebind or delete these first:", lists Matcher Verify Source, and disables the Delete button, matching the API's 409 used_by. Deleting the matcher itself (a job target, targets is on_delete: detach) lists Matcher Verify Daily as detaching and proceeds.
g. Job wizard A cron job created through the "Targets" step posts relations: {targets: [{dst_id}]} and GET /components/{id} confirms relations.targets.

make build-app: exit 0, SPA built and staged into
packages/interloper-app/src/interloper_app/static/. Those files are gitignored
(packages/interloper-app/.gitignore), so the build leaves no tracked changes and nothing
built is committed.

Follow-ups

Open items carried out of the phase, none blocking:

  • requiredUpstreams collapses a relation's alternative keys to the first one, so a
    relation satisfied by a later key still reports "Missing dependency".
  • GraphCanvas.baseEdges collapses two differently named relations between the same asset
    pair into one edge (pre-existing).
  • Outside the app: the sources page refetches a source it has just deleted
    (GET /components/{id} returns 404 in the console right after a successful delete).

By Digitl

@aaaaahaaaaa
aaaaahaaaaa force-pushed the feat/relation-model-app branch from 1093658 to 691f66d Compare September 8, 2026 18:33
on_delete decides block vs detach by itself; optional only says a
relation may be left empty or unbound, it has no say over deletion.
A used destination (optional: true, on_delete: block) now previews
as blocking, matching the API's 409 refusal.

By Digitl
…iring reports failures

Dragging a bound single-valued upstream onto another asset was refused: the
connection rules blocked a pair whenever any binding existed under that name,
and the edge could not be cleared first because the API refuses to empty a
non-optional relation. Only the identical pair is refused now; every other
pair is a repoint the API performs under a lock, and the store drops the
binding it replaced so the mirror does not keep the old edge.

The source wizard wired its cross-source legs concurrently and swallowed
every rejection, so a source could be created with none of its many-valued
legs bound while the user saw a clean save. The legs now bind one at a time
and one toast names those that failed. Falsy ids are skipped again, and a
delete carries the relation name off the edge instead of re-finding a row by
its endpoints.

By Digitl
…ments and dead fields

resourceMap read the definition's vocabulary to know which relation names
hold resources, so a component whose key has drifted out of the catalog
reported no connection at all. It now reads the bindings' own dst_kind and
needs no definition.

upstreamRelations matched a job's targets and a hook's watches too, both of
which accept assets without being upstreams; it is guarded on the
definition's kind. Also: on_delete's doc states the rule it now follows,
AssetSelect drops three DepCandidate fields nothing read, and one comment
loses an em-dash.

By Digitl
@aaaaahaaaaa
aaaaahaaaaa force-pushed the feat/relation-model-app branch from 691f66d to 4a6f8d8 Compare September 8, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant