feat(datasource intercom): relations of the reference tier (lot 2.5) - #385
Merged
christophebrun-forest merged 3 commits intoSep 7, 2026
Merged
Conversation
Declares the relations between the collections this datasource already serves, and answers them (lot 2.5, PRD-1145). Publishing a many-to-one is not free: the agent marks it filterable as soon as any column of its target is, so the interface offers `assignee:name` the moment the relation exists. The traversal is therefore the bulk of this change, not the declarations. * eight many-to-one relations towards Admin, Team, TicketState and TicketType, whose targets all shipped with lot 1; * IntercomTeamMembership, synthesized from GET /teams: Intercom carries the membership on both sides and exposes no resource for the pair, while a many-to-many needs a collection to travel through. Read-only, Intercom writing none; * admin_names and team_names replace the arrays of ids on Team and Admin, read only when a projection asks for them; * one label per object on a ticket: state_category and state_external_label are dropped, both being a hop away on the state relation and neither having ever been filterable; * a projection through a relation reads its target once per page; a condition through one is resolved by the target, exactly, and becomes a condition on the foreign key. Over fifteen matching ids, or on a key the endpoint does not filter, it is refused by name rather than approximated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the relations lot, six items. A relation group is now told apart from a group the operator wrote, and inlined into a parent that aggregates the same way: the level it added was one nothing budgeted against Intercom's two, so a scope plus a "match any" filter carrying one relation condition was refused for a nesting the operator could not find in their own filter. Inlining trades depth for width and Intercom bounds both, so past fifteen conditions the nested form is kept. Where a group is still refused for depth, the message names the relation that expanded into it rather than asking to flatten a nesting nobody wrote. Relations are read one request per target collection instead of one per relation: a ticket's state and previous_state are one read of /ticket_states, over the ids both of them name, and each relation is still nested with the columns it asked for rather than the union. An enrichment guarded on a column being named left that column nil under a projection naming nothing, which project reads as every declared column: admin_names and team_names came back nil on the one listing that publishes them. get_field_schema raised a bare ForestException on the three field names a request can get wrong, which the error translator maps to a 500. They are ValidationErrors now, so a condition reaching through a relation the toolkit cannot traverse -- a scope written on a many-to-many, say -- is the 400 it always was in intent. README: the permission note separated the enrichment, which is guarded, from the relation read, which is not; the cost of a relation stated per target collection; the page cap that bounds "read whole"; and the inlining above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two failures a many-to-many related list could reach, neither of which any tier reported. `Utils::Collection.list_relation` unwraps a through row by the name of the relation it travels through, and a target the foreign collection no longer answers leaves a nil in its place. Deleted, outside the caller's reach, or past the page cap of a datasource reading its targets whole: the row reached the JSON:API serializer, which reads a record by key, as a 500. A join whose other side is gone yields no row, not an empty one, so the mapping compacts. The other is an order asked for and not honoured. The agent hands the through collection the sort written against the collection the relation reaches -- `Filter#nest` prefixes the condition tree and not the sort -- so a related list of teammates ordered by name reached `FetchAllCollection` with a clause it carries no column for, and dropped it without a word. Every other order this datasource cannot honour is reported; this one now is too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Medium
aggregate_relation counts through rows even when their nested target resolves to nil, so /count-related reports memberships that list_relation has already removed. This makes relationship counts and pagination incorrect for deleted or inaccessible targets; apply the same target-existence filtering before aggregating.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb around line 197:
`aggregate_relation` counts through rows even when their nested target resolves to `nil`, so `/count-related` reports memberships that `list_relation` has already removed. This makes relationship counts and pagination incorrect for deleted or inaccessible targets; apply the same target-existence filtering before aggregating.
christophebrun-forest
merged commit Sep 7, 2026
3398782
into
feat/datasource-intercom
33 checks passed
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.

Lot 2.5 of PRD-1111 — PRD-1145. Branches off
feat/datasource-intercomand merges back into it.Declares the relations between the collections this datasource already serves, and answers them. Lot 1 shipped none, on the ground that a relation whose target collection is missing makes the schema refuse to boot — true of Contacts and Companies only, while
IntercomAdmin,IntercomTeam,IntercomTicketStateandIntercomTicketTypeall shipped in that same lot. The visible consequence: an assignee, a state and a type were raw ids, and a team was an array of ids with no link to its teammates.What sizes this change
Publishing a many-to-one is not free. The agent marks it filterable as soon as any column of its target is (
GeneratorField#build_many_to_one_schema), so the interface offersassignee:namethe moment the relation exists — and the datasource cannot opt out: making the relation unfilterable would mean stripping the filters off Admin and Team themselves. Left unanswered, that filter reaches a translator refusing every traversing field, which is the interface offering a filter this package then refuses.So the traversal is the bulk of the diff, and the declarations are a handful of lines.
What it does
IntercomTeamMembership, synthesized fromGET /teams: Intercom carries the membership on both sides (teams[].admin_ids,admins[].team_ids) and exposes no resource for the pair, while a many-to-many needs a collection to travel through. One record per pair, keyedteamId:adminId, read-only — Intercom writes none. Teams and Admins now navigate to each other both ways.admin_names/team_namesreplace the arrays of ids on Team and Admin: one readable form plus a relation to navigate, rather than two ways to read one fact. Read only when a projection asks for them, and a token that cannot read the other side costs the column, never the page or the relation.state_categoryandstate_external_labelare dropped — a hop away on thestaterelation, and neither was ever filterable, so no segment, scope or saved filter could rest on them.state_labelandticket_type_namestay, a list view having to read without a join.ORof N equalities, which counts against the 15 conditions a group allows. Past that: refused, naming the relation the operator filtered on rather than the key it resolved to.state— is refused before the target is read, saying which of the two it is: read and navigate, not filter.Decisions taken while implementing
projectslices on the columns.state:categoryused to read/ticket_statesfor nothing before refusing.admins:nameis refused saying which relations are filtered through, rather than claiming the relation does not exist.Known, and documented rather than discovered
state_idis still to probe on/tickets/search: the measured table carries no filter on a state id, so thestaterelation navigates without translating into a filter. In the refusal table and in the README, not in an assumption.Verification
and/ornext to native ones, and the real related-list path throughUtils::Collection.list_relation.README: a
## Relationssection (the table, the synthesized membership, the read cost, the null semantics) and a### Through a relationsubsection under Filtering.🤖 Generated with Claude Code
Note
Add reference-tier relations and
IntercomTeamMembershipto Intercom datasourceIntercomTeamandIntercomAdmin.Ticket,Admin,Team, andConversationcollections to declare relations such asadmin_assignee,teams, andclosed_by, replacing raw ID lists with derivedadmin_namesandteam_namescolumns.team_ids,admin_ids,state_category, andstate_external_labelcolumns from the Intercom schemas; any existing consumers relying on these fields will break.Macroscope summarized e1ae9b4.