From f783505e38f21c3ea218e3e249756eb1dbe349d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 16:37:04 +0000 Subject: [PATCH 1/4] fix(plugin-detail): compile the reference rail's parent scope by relationship arity `record:reference_rail` compiled an author-supplied `relationshipField` as bare equality twice, in two different grammars: the `$filter` on the wire and the `filter[]=` URL behind "View All". On a `multiple: true` relationship the stored value is an array, equality asks whether that whole array is one id, and the driver refuses with `400 INVALID_FILTER`. The filter half now goes through `composeParentScopeFilter` from `@object-ui/core`, the one compiler the related list's rows and tab badge already share. The child schemas are resolved before the reads, not after a refusal: the rail dispatches once per (record + entries) and has no second attempt to correct itself with. Resolution is best-effort, never a gate. The link half cannot be expressed: no URL spelling on this surface carries membership, so it is suppressed on a multi-value relationship and the reason is logged once. Single-value entries keep both halves byte for byte. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../8883-reference-rail-parent-scope-arity.md | 35 ++ ...erence-rail.parentScopeArity-8883.test.tsx | 388 ++++++++++++++++++ .../src/renderers/record-reference-rail.tsx | 148 ++++++- 3 files changed, 553 insertions(+), 18 deletions(-) create mode 100644 .changeset/8883-reference-rail-parent-scope-arity.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-reference-rail.parentScopeArity-8883.test.tsx 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..95e449e44e --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.parentScopeArity-8883.test.tsx @@ -0,0 +1,388 @@ +/** + * 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`. + * + * 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; + +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 = warnSpy.mock.calls.filter( + (c) => typeof c[0] === 'string' && c[0].includes('RecordReferenceRail'), + ); + expect(suppressionWarnings.length).toBe(1); + expect(suppressionWarnings[0][0]).toContain(MULTI_REF); + expect(suppressionWarnings[0][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( + warnSpy.mock.calls.filter( + (c) => typeof c[0] === 'string' && c[0].includes('RecordReferenceRail'), + ).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..e6f44dd185 100644 --- a/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -16,12 +16,47 @@ * `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`, the same predicate the + * driver that executes the query decides on, and two readers of one question + * disagreeing is the entire defect class. + * + * ## 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 +193,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 +227,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 +258,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 +362,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 +392,7 @@ export const RecordReferenceRailRenderer: React.FC )} - {appName && parentId && ( + {appName && parentId && !suppressViewAll && ( Date: Fri, 11 Sep 2026 16:51:41 +0000 Subject: [PATCH 2/4] test(plugin-detail): type the rail's warn-channel reader in the 8883 pin `tsc -p tsconfig.test.json` reported TS7006 on the two inline `warnSpy.mock.calls.filter` predicates. One typed reader now serves both assertions. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...erence-rail.parentScopeArity-8883.test.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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 index 95e449e44e..4d5b872483 100644 --- 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 @@ -236,6 +236,14 @@ const viewAllLink = (): 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(() => {}); @@ -302,12 +310,10 @@ describe('reference rail parent scope — compiled by ARITY (objectui#8883)', () }); // 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 = warnSpy.mock.calls.filter( - (c) => typeof c[0] === 'string' && c[0].includes('RecordReferenceRail'), - ); + const suppressionWarnings = railWarnings(); expect(suppressionWarnings.length).toBe(1); - expect(suppressionWarnings[0][0]).toContain(MULTI_REF); - expect(suppressionWarnings[0][0]).toContain('View All'); + 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 () => { @@ -359,11 +365,7 @@ describe('reference rail parent scope — compiled by ARITY (objectui#8883)', () expect(drawn).toBeGreaterThan(0); expect(badgeText()).toBe(String(drawn)); // Nothing was suppressed, so nothing was said. - expect( - warnSpy.mock.calls.filter( - (c) => typeof c[0] === 'string' && c[0].includes('RecordReferenceRail'), - ).length, - ).toBe(0); + expect(railWarnings().length).toBe(0); }); it('DEGRADATION CONTROL — an adapter with no `getObjectSchema` still reads rows, on the historical wire', async () => { From 8fd5fba02417e4ba1993ceb7da098a9f7397c979 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 17:11:35 +0000 Subject: [PATCH 3/4] docs(plugin-detail): state the measured spec/driver arity divergence in the rail header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header claimed the seam's verdict was "the same predicate the driver that executes the query decides on". It is not. Measured at source: the spec asks `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && multiple === true)`, while the SQL driver's `isJsonField` asks `JSON_COLUMN_TYPES.has(type) || !!field.multiple` — the flag on any type — so `{type:'master_detail', multiple:true}` answers false to one and true to the other. The paragraph now states the relationship it measured instead of an identity, names which rule this renderer follows and why, and cites objectstack#17469, which owns the divergence. The ⛔ "no local arity rule here" instruction is kept; it was the true half. Prose only: every changed line is inside the file docblock. No predicate, no assertion and no executable line moved. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../src/renderers/record-reference-rail.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/packages/plugin-detail/src/renderers/record-reference-rail.tsx index e6f44dd185..f67f39dbbe 100644 --- a/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -29,10 +29,25 @@ * 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`, the same predicate the - * driver that executes the query decides on, and two readers of one question - * disagreeing is the entire defect class. + * ⛔ 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 * From 33bb17450a8c093905bd147e42b7e6254312bb79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 18:16:07 +0000 Subject: [PATCH 4/4] docs(plugin-detail): cite the spec/driver arity divergence card in the 8883 rail pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#8937's residue sweep (PR #9195, pin three) requires every tracked file under `packages` that names both the spec arity predicate and the driver to cite the card that owns their disagreement. This test file entered that population when it landed and did not cite it, so the sweep reds on the combined tree even though it is green on this branch's own base — which is what ejected the PR from the merge queue. The docblock now states the relationship it was implying: the spec gates on the field TYPE, `driver-sql` decides storage on `!!field.multiple` whatever the type, the two part company for a `multiple: true` def on a type outside the spec's multi-capable set, and objectstack#17469 owns which is right. It also records why these cases sit clear of that disagreement — they relate through a `lookup` field, which both rules call multi-valued. Prose only: the diff adds docblock lines and removes none. No predicate, no assertion and no executable line moved. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...ord-reference-rail.parentScopeArity-8883.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 4d5b872483..6226c608ce 100644 --- 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 @@ -26,6 +26,18 @@ * 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.