diff --git a/.changeset/8883-reference-rail-parent-scope-arity.md b/.changeset/8883-reference-rail-parent-scope-arity.md new file mode 100644 index 0000000000..0f3e585191 --- /dev/null +++ b/.changeset/8883-reference-rail-parent-scope-arity.md @@ -0,0 +1,35 @@ +--- +"@object-ui/plugin-detail": patch +--- + +fix(plugin-detail): `record:reference_rail` compiles its parent scope by relationship ARITY, and stops offering a link it cannot express + +The rail took an author-supplied `relationshipField` per entry and compiled it +as bare equality TWICE, in two different grammars: the `$filter` it puts on the +wire and the `filter[]=` URL its "View All" link points at. When +the named field is `multiple: true` — `Field.user({ multiple: true })` is the +platform's own shape — the stored value is an array, `=` asks whether that +whole array IS one id, and the driver refuses with `400 INVALID_FILTER` while +prescribing `$contains`. The rail was empty for a perfectly legal authoring +choice, and for one the platform reaches with no authoring at all: a child +object's `lookup` field is derived into a rail entry whatever its arity. + +The `$filter` half now goes through `composeParentScopeFilter` from +`@object-ui/core` — the one compiler the related list's rows and its tab badge +already share — so a multi-value relationship is queried by membership and +everything else keeps `=` byte for byte. No authoring key was added: the author +is naming a relationship, and its storage form is the renderer's business. The +child object's field defs are resolved BEFORE the reads rather than after a +refusal, because the rail dispatches its queries once per (record + entries) +and has no second attempt to correct itself with. Resolution is best-effort, +not a gate: an adapter that cannot serve metadata still reads rows, with the +equality wire it has always sent. + +The link half is the COST. No URL spelling on this surface carries membership — +the data surface recognises `gte`/`lte`/`gt`/`lt` and drops any other suffix, +and the route the rail actually links to parses equality only — so a hopeful +`[contains]` suffix would not narrow the destination at all and "View All" +would open the entire child table dressed as this parent's related records. +Rather than ship a rail whose rows and link disagree, the link is suppressed on +a multi-value relationship and the reason is logged once. Single-value entries +keep their link with a byte-identical href. diff --git a/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.parentScopeArity-8883.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.parentScopeArity-8883.test.tsx new file mode 100644 index 0000000000..6226c608ce --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.parentScopeArity-8883.test.tsx @@ -0,0 +1,402 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8883 — `record:reference_rail` compiles its parent scope by the + * relationship field's ARITY, and refuses to render a "View All" link it + * cannot express. + * + * The rail took an author-supplied `relationshipField` and compiled it as bare + * equality TWICE, in two different grammars: the `$filter` it puts on the wire + * and the `filter[]=` URL its "View All" link points at. On a + * `multiple: true` relationship the stored value is an ARRAY of parent ids, so + * equality asks whether that whole array IS one id — which `driver-sql` + * refuses with `400 INVALID_FILTER` while prescribing `$contains`. + * + * ## Two halves, two different answers + * + * The `$filter` half is repairable and is repaired: it goes through + * `composeParentScopeFilter` from `@object-ui/core`, the ONE compiler the + * related list's rows (objectui#7299) and its tab badge (objectui#8882) + * already share, whose verdict is `@objectstack/spec/data`'s own + * `isMultiValueField`. + * + * ⚠️ That verdict is the SPEC's, and it is NOT the driver's — the two rules + * are not one predicate, and this file does not assert that they agree. The + * spec gates on the field TYPE (`MULTI_OPTION_TYPES.has(type) || + * (MULTI_CAPABLE_TYPES.has(type) && multiple === true)`), while `driver-sql` + * decides storage on `!!field.multiple` whatever the type is, so a + * `multiple: true` def on a type OUTSIDE the spec's multi-capable set is + * single-valued to the renderer and a JSON column to the driver. Which of the + * two is right is owned upstream by objectstack#17469; ⛔ nothing here decides + * it. The cases below stay clear of that disagreement on purpose: they relate + * through a `lookup` field, which both rules call multi-valued, so what they + * measure is the rail's compilation and not the open question. + * + * The LINK half is not repairable at this layer and is not repaired: no URL + * spelling on this surface carries membership, so the link is SUPPRESSED on a + * multi-value relationship rather than pointed at an unscoped child table. + * Landing the filter alone would have produced a rail that shows the right + * rows above a link that goes somewhere else — a disagreement invisible until + * the user clicks. Both halves are pinned below, and so is the LINK's + * survival on the single-value arm that was already correct. + * + * ## Why the fake backend REFUSES equality instead of answering it + * + * The defect is invisible to a permissive backend: a fake that answers bare + * equality against an array-valued column with "no rows" turns a driver-level + * refusal into a plausible empty rail, and one that answers with the rows + * makes the bug look like a working feature. Either way every assertion here + * would be satisfied by the broken filter. The evaluator below therefore + * throws on that exact combination — and on any filter shape it does not + * recognise — and `FIXTURE` asserts that it does, in both directions. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { RecordContextProvider } from '@object-ui/react'; + +import { RecordReferenceRailRenderer } from '../record-reference-rail'; + +/** The rail gates its queries on an IntersectionObserver. Report intersecting + * immediately so the fetch effect runs deterministically under jsdom. */ +class ImmediateIO { + constructor(private cb: (records: { isIntersecting: boolean }[]) => void) {} + observe() { this.cb([{ isIntersecting: true }]); } + disconnect() {} + unobserve() {} +} + +const APP = 'demo'; +const PARENT = 'account'; +const CHILD = 'contact'; +const RECORD_ID = 'A1'; +const OTHER_ID = 'A2'; + +/** The multi-value relationship field — an ARRAY of parent ids per child row. */ +const MULTI_REF = 'accounts'; +/** The single-value control's field — one parent id per child row. */ +const SINGLE_REF = 'account_id'; + +/** + * The child object in the two arities this card is about. Only the ONE field + * member differs — `multiple: true` — so every difference the rail shows is + * attributable to the arity and to nothing else. + */ +const multiChildSchema = { + name: CHILD, + label: 'Contact', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + [MULTI_REF]: { type: 'lookup', reference: PARENT, multiple: true, label: 'Accounts' }, + }, +}; + +const singleChildSchema = { + name: CHILD, + label: 'Contact', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + [SINGLE_REF]: { type: 'lookup', reference: PARENT, label: 'Account' }, + }, +}; + +const MULTI_ROWS = [ + { id: 'c1', name: 'Ada', [MULTI_REF]: [RECORD_ID] }, + { id: 'c2', name: 'Grace', [MULTI_REF]: [OTHER_ID, RECORD_ID] }, + { id: 'c3', name: 'Other Parent Only', [MULTI_REF]: [OTHER_ID] }, +]; + +const SINGLE_ROWS = [ + { id: 'c1', name: 'Ada', [SINGLE_REF]: RECORD_ID }, + { id: 'c2', name: 'Grace', [SINGLE_REF]: RECORD_ID }, + { id: 'c3', name: 'Other Parent Only', [SINGLE_REF]: OTHER_ID }, +]; + +// --- the filter evaluator --------------------------------------------------- + +/** What `driver-sql` answers the equality form with on a multi-value column. */ +class InvalidFilterError extends Error { + code = 'INVALID_FILTER'; + status = 400; +} + +/** + * Compare one field against one scalar, the way a real driver does. An + * array-valued column under a bare scalar equality is REFUSED, not answered. + */ +function scalarEquals(stored: unknown, value: unknown, field: string): boolean { + if (Array.isArray(stored)) { + throw new InvalidFilterError( + `[fixture] 400 INVALID_FILTER: '${field}' stores multiple values; ` + + 'equality cannot be evaluated against an array (use $contains)', + ); + } + return stored === value; +} + +/** Membership against a multi-value column; a scalar column holds one member. */ +function containsValue(stored: unknown, value: unknown): boolean { + return Array.isArray(stored) ? stored.includes(value) : stored === value; +} + +/** + * Evaluate one filter node against a row. Handles exactly the MongoDB-style + * object this rail puts on the wire and THROWS on anything else — a permissive + * evaluator answers "all rows" for a shape it does not understand, and every + * assertion here would be satisfied by it. + */ +function matchesFilter(row: Record, node: unknown): boolean { + if (node === undefined || node === null) { + throw new Error('[fixture] a rail query reached the backend with no $filter at all'); + } + if (typeof node !== 'object' || Array.isArray(node)) { + throw new Error(`[fixture] unsupported filter: ${JSON.stringify(node)}`); + } + return Object.entries(node as Record).every(([field, cond]) => { + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + return Object.entries(cond as Record).every(([op, value]) => { + if (op === '$eq') return scalarEquals(row[field], value, field); + if (op === '$contains') return containsValue(row[field], value); + throw new Error(`[fixture] unsupported operator '${op}' on '${field}'`); + }); + } + return scalarEquals(row[field], cond, field); + }); +} + +/** + * A rail-shaped DataSource. `schema` of `null` models the adapter that cannot + * serve metadata at all — the degradation control — by omitting + * `getObjectSchema` entirely. + */ +function makeDataSource(rows: Record[], schema: unknown | null) { + const find = vi.fn(async (objectName: string, params: any) => { + if (objectName !== CHILD) return { data: [], total: 0 }; + const matched = rows.filter((r) => matchesFilter(r, params?.$filter)); + const top = typeof params?.$top === 'number' ? params.$top : matched.length; + return { data: matched.slice(0, top), total: matched.length }; + }); + const ds: Record = { find }; + if (schema !== null) { + ds.getObjectSchema = vi.fn(async (objectName: string) => + objectName === CHILD ? schema : undefined, + ); + } + return ds as any; +} + +function renderRail(dataSource: any, relationshipField: string) { + return render( + + + + + + } + /> + + , + ); +} + +/** The child queries the rail actually issued, newest last. */ +const childQueries = (ds: any): Record[] => + ds.find.mock.calls.filter((c: any[]) => c[0] === CHILD).map((c: any[]) => c[1]); + +/** Wait until the rail has settled: the entry's skeletons are gone. */ +async function settled(ds: any): Promise[]> { + await waitFor(() => { + expect(childQueries(ds).length).toBeGreaterThan(0); + expect(document.querySelector('.tabular-nums')).not.toBeNull(); + }); + return childQueries(ds); +} + +/** The count badge's digits. */ +const badgeText = (): string => + document.querySelector('.tabular-nums')?.textContent?.trim() ?? ''; + +/** Which fixture rows the rail actually drew. */ +const drawnRowNames = (rows: Record[]): string[] => + rows.map((r) => r.name as string).filter((n) => screen.queryByText(n) !== null); + +const viewAllLink = (): HTMLAnchorElement | null => + screen.queryByRole('link', { name: /View All/i }) as HTMLAnchorElement | null; + +let warnSpy: ReturnType; + +/** The rail's own `console.warn` calls, by their first argument. */ +const railWarnings = (): string[] => + (warnSpy.mock.calls as unknown[][]) + .map((c) => c[0]) + .filter((first): first is string => + typeof first === 'string' && first.includes('RecordReferenceRail'), + ); + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', ImmediateIO as unknown as typeof IntersectionObserver); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + cleanup(); +}); + +describe('reference rail parent scope — compiled by ARITY (objectui#8883)', () => { + it('FIXTURE — the backend refuses equality on the array column and answers membership', () => { + // Forward: equality against the stored ARRAY is a refusal, not an answer. + expect(() => matchesFilter(MULTI_ROWS[0], { [MULTI_REF]: RECORD_ID })).toThrow( + /INVALID_FILTER/, + ); + // Reverse: membership answers it, and answers it SELECTIVELY — two of the + // three rows carry this parent, so an evaluator that admitted everything + // (the one bug that would make this whole file lie) reads 3 here. + expect( + MULTI_ROWS.filter((r) => matchesFilter(r, { [MULTI_REF]: { $contains: RECORD_ID } })).map( + (r) => r.id, + ), + ).toEqual(['c1', 'c2']); + // The single-value control's column is answered by plain equality, so the + // LIVE CONTROL below is not passing for want of a working evaluator. + expect( + SINGLE_ROWS.filter((r) => matchesFilter(r, { [SINGLE_REF]: RECORD_ID })).map((r) => r.id), + ).toEqual(['c1', 'c2']); + // And an unrecognised shape is refused rather than answered "all rows". + expect(() => matchesFilter(MULTI_ROWS[0], { [MULTI_REF]: { $in: [RECORD_ID] } })).toThrow( + /unsupported operator/, + ); + }); + + it('SUBJECT — a multi-value relationship is queried by MEMBERSHIP, selectively', async () => { + const ds = makeDataSource(MULTI_ROWS, multiChildSchema); + renderRail(ds, MULTI_REF); + const queries = await settled(ds); + expect(queries[queries.length - 1].$filter).toEqual({ + [MULTI_REF]: { $contains: RECORD_ID }, + }); + await waitFor(() => { + expect(drawnRowNames(MULTI_ROWS)).toEqual(['Ada', 'Grace']); + }); + // TWO ZEROS ARE EQUAL — parity between the badge and the drawn rows is only + // evidence at a positive count. + const drawn = drawnRowNames(MULTI_ROWS).length; + expect(drawn).toBeGreaterThan(0); + expect(badgeText()).toBe(String(drawn)); + // The other parent's row is excluded — a rail that fetched the whole child + // table would draw it and badge 3. + expect(screen.queryByText('Other Parent Only')).toBeNull(); + }); + + it('SUBJECT — the "View All" link is SUPPRESSED on a multi-value relationship, loudly', async () => { + const ds = makeDataSource(MULTI_ROWS, multiChildSchema); + renderRail(ds, MULTI_REF); + await settled(ds); + // The rows above it are correct; the link is the half no URL spelling on + // this surface can express, so it is absent rather than unscoped. + await waitFor(() => { + expect(viewAllLink()).toBeNull(); + }); + // Absent on screen ⇒ the developer console is the only place it can be + // said at all, and it IS said — once, naming the field and the reason. + const suppressionWarnings = railWarnings(); + expect(suppressionWarnings.length).toBe(1); + expect(suppressionWarnings[0]).toContain(MULTI_REF); + expect(suppressionWarnings[0]).toContain('View All'); + }); + + it('MEASURED DIFFERENCE — the rail resolves the arity BEFORE its one read', async () => { + // Not symmetry for its own sake: the rail makes a DIFFERENT trade with this + // seam than `RelatedList`'s rows do, and the difference is worth pinning + // because a reader would otherwise call it a bug. + // + // The ROWS deliberately attempt equality, are refused, and refetch once the + // arity lands — their fetch effect re-runs on the verdict. This rail cannot + // copy that: its `fetchedSigRef` latch keys on (parentId + entries), the + // arity is in neither, so a refused first attempt would be the ONLY + // attempt and the entry would sit on its error state. It also renders a + // navigational link decided by the same verdict, so deferring the verdict + // would ship the right rows under the wrong link. + const ds = makeDataSource(MULTI_ROWS, multiChildSchema); + renderRail(ds, MULTI_REF); + const queries = await settled(ds); + // Exactly ONE read, and it was already the membership question — no + // refused first attempt, and therefore nothing to recover from. + expect(queries.length).toBe(1); + expect(queries[0].$filter).toEqual({ [MULTI_REF]: { $contains: RECORD_ID } }); + // The metadata read that decided it happened first. + expect(ds.getObjectSchema).toHaveBeenCalledWith(CHILD); + expect(ds.getObjectSchema.mock.invocationCallOrder[0]).toBeLessThan( + ds.find.mock.invocationCallOrder[0], + ); + }); + + it('LIVE CONTROL — a single-value entry is byte-identical on BOTH halves', async () => { + const ds = makeDataSource(SINGLE_ROWS, singleChildSchema); + renderRail(ds, SINGLE_REF); + const queries = await settled(ds); + // Half one: the plain MongoDB-style equality object this rail has always + // sent, not a freshly lowered AST that means the same thing. + expect(queries.length).toBe(1); + expect(queries[0].$filter).toEqual({ [SINGLE_REF]: RECORD_ID }); + expect(queries[0].$count).toBe(true); + // Half two: the rendered href, character for character what it was before + // this card. + const link = viewAllLink(); + expect(link).not.toBeNull(); + expect(link!.getAttribute('href')).toBe( + `/apps/${APP}/${CHILD}?filter%5B${SINGLE_REF}%5D=${RECORD_ID}`, + ); + await waitFor(() => { + expect(drawnRowNames(SINGLE_ROWS)).toEqual(['Ada', 'Grace']); + }); + const drawn = drawnRowNames(SINGLE_ROWS).length; + expect(drawn).toBeGreaterThan(0); + expect(badgeText()).toBe(String(drawn)); + // Nothing was suppressed, so nothing was said. + expect(railWarnings().length).toBe(0); + }); + + it('DEGRADATION CONTROL — an adapter with no `getObjectSchema` still reads rows, on the historical wire', async () => { + // ⛔ The arity resolution is NOT a gate. An adapter that cannot serve + // metadata would otherwise render every rail entry empty — trading this + // card's loud 400 on one relationship shape for a silent blank rail on all + // of them. + const ds = makeDataSource(SINGLE_ROWS, null); + expect(ds.getObjectSchema).toBeUndefined(); + renderRail(ds, SINGLE_REF); + const queries = await settled(ds); + expect(queries.length).toBe(1); + expect(queries[0].$filter).toEqual({ [SINGLE_REF]: RECORD_ID }); + await waitFor(() => { + expect(drawnRowNames(SINGLE_ROWS)).toEqual(['Ada', 'Grace']); + }); + expect(drawnRowNames(SINGLE_ROWS).length).toBeGreaterThan(0); + // And the link survives: an unknown arity keeps today's affordance rather + // than losing it on a suspicion. + expect(viewAllLink()).not.toBeNull(); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/packages/plugin-detail/src/renderers/record-reference-rail.tsx index a5086aa261..f67f39dbbe 100644 --- a/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -16,12 +16,62 @@ * `RecordContext`. We deliberately query with `$top` only — this rail is * a snapshot, not a paginated list — and silently degrade to "—" on * failure so a misconfigured entry never blanks the whole rail. + * + * ## The parent scope is compiled by the relationship field's ARITY + * + * An entry names a `relationshipField` on the child object and nothing about + * how that field STORES the link. A `multiple: true` relationship + * (`Field.user({ multiple: true })` is the platform's own shape) persists an + * ARRAY of parent ids, so the question is MEMBERSHIP and not equality — + * equality asks whether that whole stored array IS one id, which `driver-sql` + * refuses with `400 INVALID_FILTER` while prescribing `$contains`. + * + * The condition is therefore composed by `@object-ui/core`'s + * `composeParentScopeFilter` (objectui#8883), the ONE compiler the related + * list's rows and the tab badge already share (objectui#7299, objectui#8882). + * ⛔ Do not add a local arity rule here, however small. The seam's verdict is + * `@objectstack/spec/data`'s own `isMultiValueField`, and a local + * approximation is wrong against it in BOTH directions, not merely incomplete: + * `multiselect` / `checkboxes` / `tags` persist an array with no flag at all, + * and `multiple: true` is INERT on a type outside the spec's multi-capable + * set. Two readers of one question, drifted, is the entire defect class. + * + * ⚠️ The QUERY rule and the STORAGE rule are two rules, and ⛔ this file does + * not claim they are one predicate. Measured at source: the spec asks + * `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && multiple + * === true)`, while the SQL driver's own `isJsonField` asks + * `JSON_COLUMN_TYPES.has(type) || !!field.multiple` — the flag on ANY type. So + * `{ type: 'master_detail', multiple: true }` answers `false` to the spec and + * `true` to the driver, and the two part company for exactly the + * flag-on-a-non-multi-capable-type case. ⛔ Nothing here decides which is + * right: that divergence is owned upstream by objectstack#17469. This renderer + * follows the SPEC, because the spec is what the authoring surface is + * validated against and what the seam already compiles on — and a second + * opinion at this call site would be the defect above, one layer up. + * + * ## The "View All" link cannot follow the rows there + * + * The link builds a `filter[]=` URL into the console's object + * list. That grammar has no membership operator and no third spelling: the + * ADR-0055 data surface recognises `gte`/`lte`/`gt`/`lt` and DROPS any other + * suffix, and the route this link actually targets parses equality only. A + * hopeful `[contains]` suffix therefore does not narrow the destination at + * all. Rather than send the user to an unscoped child table dressed as this + * parent's related records, the link is SUPPRESSED on a multi-value + * relationship and the reason is logged once — the same "empty and loud beats + * wider and quiet" posture `RelatedList`'s raw-URL fallback takes for the same + * grammar. Losing the affordance there is the COST of the repair. */ import React from 'react'; import { Link, useParams } from 'react-router-dom'; import { useRecordContext, useSafeFieldLabel } from '@object-ui/react'; import { cn, Card, CardHeader, CardTitle, CardContent, Badge, Skeleton } from '@object-ui/components'; +import { + composeParentScopeFilter, + isMultiValueRelationship, + type FieldContainerLike, +} from '@object-ui/core'; import { ChevronRight } from 'lucide-react'; import type { ReferenceRailEntry } from '@objectstack/spec/ui'; import { useDetailTranslation } from '../useDetailTranslation'; @@ -158,6 +208,18 @@ export const RecordReferenceRailRenderer: React.FC obs.disconnect(); }, [railVisible]); + // The CHILD objects' field defs, keyed by object name — the METADATA the + // parent-scope seam draws its arity verdict from. Filled by the fetch effect + // below BEFORE it reads any rows, and read a second time at render time by + // the "View All" link, so both halves of an entry answer from one source. + // Empty until a schema proves otherwise: the seam then compiles equality, + // which is byte for byte the wire this rail has always sent. + const [entryFields, setEntryFields] = React.useState>({}); + // One warning per (object, field) per mounted rail — the link suppression + // below is silent on screen by construction, so the developer channel is the + // only place it can be said at all. Fired from the effect, never from render. + const warnedSuppressedLinks = React.useRef>(new Set()); + const entriesSig = JSON.stringify(entries.map((e) => `${e.objectName}:${e.relationshipField}:${e.limit ?? 3}`)); React.useEffect(() => { if (!railVisible) return; @@ -180,11 +242,16 @@ export const RecordReferenceRailRenderer: React.FC { + const fetchEntry = async (entry: ReferenceRailEntry, fields: FieldContainerLike) => { const key = entry.objectName; try { const res: any = await dataSource.find(entry.objectName, { - $filter: { [entry.relationshipField]: parentId }, + // The parent-relationship condition, compiled to match the field's + // ARITY by the one seam the rows and the tab badge already use + // (objectui#8883). With no `fields` it compiles equality — the + // historical wire — so an adapter that cannot serve metadata is no + // worse off than before this card. + $filter: composeParentScopeFilter(entry.relationshipField, parentId, fields), $top: entry.limit ?? 3, $count: true, }); @@ -206,22 +273,70 @@ export const RecordReferenceRailRenderer: React.FC { - while (mountedRef.current) { - const entry = queue.shift(); - if (!entry) return; - await fetchEntry(entry); + void (async () => { + // ARITY FIRST, then the reads — and the rail's reason for that order is + // its OWN, not the tab badge's. + // + // `RelatedList` deliberately attempts equality, is refused, and refetches + // once the arity lands; it can, because its fetch effect re-runs on the + // verdict. This rail cannot: `fetchedSigRef` above latches on + // (parentId + entries), and the arity is in NEITHER — so a probe-then- + // correct design would make the refused first attempt the ONLY attempt, + // and the entry would sit on its error state until the user navigated to + // another record. The link half compounds it: the destination href is + // decided by the same verdict, so deferring it would ship exactly the + // disagreement this card exists to prevent — right rows, wrong link. + // + // ⛔ NOT gated on "a schema loaded", which is a different thing: an + // adapter without `getObjectSchema`, or one whose fetch rejects, still + // reads rows, with the equality wire it has always sent. Gating would + // trade this card's loud 400 on one relationship shape for a silently + // empty rail on every entry in the app. + const fieldsFor = new Map(); + if (typeof dataSource.getObjectSchema === 'function') { + await Promise.all( + Array.from(new Set(entries.map((e) => e.objectName))).map(async (name) => { + try { + fieldsFor.set(name, (await dataSource.getObjectSchema(name))?.fields); + } catch { + // Equality it is — the wire this rail has always sent. + } + }), + ); } - }; - const workers = Array.from( - { length: Math.min(MAX_CONCURRENCY, queue.length) }, - () => runWorker(), - ); - void Promise.all(workers); + if (!mountedRef.current) return; + setEntryFields(Object.fromEntries(fieldsFor)); + for (const entry of entries) { + if (!isMultiValueRelationship(fieldsFor.get(entry.objectName), entry.relationshipField)) { + continue; + } + const warnKey = `${entry.objectName}.${entry.relationshipField}`; + if (warnedSuppressedLinks.current.has(warnKey)) continue; + warnedSuppressedLinks.current.add(warnKey); + console.warn( + `[RecordReferenceRail] "${entry.objectName}" relates through the multi-value field ` + + `"${entry.relationshipField}", so the "View All" link is suppressed for it. The ` + + 'console list URL\'s `filter[]=` grammar has no membership operator ' + + 'and no unrecognised suffix narrows it, so the link would open the entire child ' + + "table dressed as this parent's related records. The rail's own rows are still " + + 'scoped correctly.', + ); + } + // Concurrency-capped pool: drain the entries a few at a time instead of + // firing all N at once, so the rail never floods the backend in a burst. + const MAX_CONCURRENCY = 3; + const queue = [...entries]; + const runWorker = async () => { + while (mountedRef.current) { + const entry = queue.shift(); + if (!entry) return; + await fetchEntry(entry, fieldsFor.get(entry.objectName)); + } + }; + await Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENCY, queue.length) }, () => runWorker()), + ); + })(); }, [railVisible, dataSource, parentId, entriesSig]); // useState must run unconditionally — declared above the empty-entries early @@ -262,6 +377,18 @@ export const RecordReferenceRailRenderer: React.FC { const key = entry.objectName; const state = states[key] || { loading: true, total: 0, items: [] }; + // The link's URL grammar cannot express MEMBERSHIP (see the file + // header), so on a multi-value relationship the only honest "View All" + // is no "View All": the href below would drop the parent scope + // entirely and open the whole child table. The verdict is the same + // seam's, off the same metadata the rows were scoped with, and it is + // `false` until a schema PROVES otherwise — a single-value entry and + // an entry whose schema never resolved both keep today's link, href + // byte for byte. + const suppressViewAll = isMultiValueRelationship( + entryFields[key], + entry.relationshipField, + ); const title = entry.title || (i18n?.objectLabel @@ -280,7 +407,7 @@ export const RecordReferenceRailRenderer: React.FC )} - {appName && parentId && ( + {appName && parentId && !suppressViewAll && (