feat(app): relations by name - #325
Open
aaaaahaaaaa wants to merge 8 commits into
Open
Conversation
aaaaahaaaaa
force-pushed
the
feat/relation-model-app
branch
from
September 8, 2026 18:33
1093658 to
691f66d
Compare
…derived by kind By Digitl
…y-valued upstream picker By Digitl
…er relations By Digitl
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
force-pushed
the
feat/relation-model-app
branch
from
September 8, 2026 19:48
691f66d to
4a6f8d8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
relationsmap ({name: {kind, key, many, optional, on_delete}}) and speak to the API inthose terms:
POST /components/sendsrelations: {<name>: [{dst_id}]}, and single-edgework 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 and titles it from the relation name, so a
connectionrelation renders a"Connection" step and
targetsa "Targets" step. The recap rows and the step icons keyoff the same names.
x-fetchproviders are resolved by relation name too(
connection.accountsasks the backend for the resource bound toconnection).many: truegets a multi-selectcross-source picker instead of a single choice. The campaign matcher's
campaignsrelation lists every candidate source and binds one relation row per selection.
*.campaignsoffers every source thatdeclares a matching asset, rather than a single hardcoded key.
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.
on_deleteoutcomes: adetaching 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_deletealone, notoptional, so a used destination(
optional: true, on_delete: block) now previews as blocking, matching the API's refusal.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.
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.
(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, andthe catalog's
relationsmap. 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-existingvue/no-v-htmlwarning inexecutions/ErrorDetailModal.vue).pnpm exec nuxt typecheck: exit 0, noerror TS.slotsstyling keys, VueuseSlots, Vue Flow'source' | 'target'handle types,component kinds (
fetchAll(['destination'])), warning categories and prose. Fouridentifiers 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 anddropped), instance on port 3100:
facebook_ads_connection,tiktok_ads_connectionandadservice_connectioncreated 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.csv_destinationcreated with abase_pathunder the scratch directory.facebook_adswizard shows a "Connection" step and a "Destination" step, both titled from the relation namesconnectionanddestinations, and the details recap lists them by name. Its save is blocked offline:account_idis a requiredx-fetchfield whose provider isconnection.accounts, which cannot resolve against dummy credentials, so the combobox and the Create button stay disabled.facebook_adsandtiktok_adswere therefore created through the API with the same relation payload. A full wizard save was done withadservice(a connector with no vendor-fetch required field), whose POST body isrelations: {connection: [{dst_id}], destinations: [{dst_id}]}. Every source row shows theCSV Verify Destbadge andGET /components/{id}returns both relations by name.campaign_matcherasset step showscampaign_matcheswith1 depand aCampaignsrow carrying a many-valued "Select sources…" picker; the picker lists both connectors. Selecting both posts twoPOST /components/{asset}/relations {"name": "campaigns", ...}calls, andGET /components/relations?src_kind=asset&dst_kind=assetlists two rows namedcampaigns.campaignsis 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.on_deletealone 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 testtest_bound_destination_blocks_delete_and_names_referrerinpackages/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:", listsMatcher Verify Source, and disables the Delete button, matching the API's 409used_by. Deleting the matcher itself (a job target,targetsison_delete: detach) listsMatcher Verify Dailyas detaching and proceeds.relations: {targets: [{dst_id}]}andGET /components/{id}confirmsrelations.targets.make build-app: exit 0, SPA built and staged intopackages/interloper-app/src/interloper_app/static/. Those files are gitignored(
packages/interloper-app/.gitignore), so the build leaves no tracked changes and nothingbuilt is committed.
Follow-ups
Open items carried out of the phase, none blocking:
requiredUpstreamscollapses a relation's alternative keys to the first one, so arelation satisfied by a later key still reports "Missing dependency".
GraphCanvas.baseEdgescollapses two differently named relations between the same assetpair into one edge (pre-existing).
(
GET /components/{id}returns 404 in the console right after a successful delete).By Digitl