fix(related-list): compile the badge's parent scope by relationship arity - #9184
Conversation
…rity The related-list tab badge compiled the parent-relationship condition with a second, independent compiler that always sent bare equality. The ROW query has compiled that condition to match the relationship field's ARITY since objectui#7299, so on a `multiple: true` relationship the two sides asked the driver two different questions: the rows rendered and the badge did not, because the driver refuses equality on an array-valued column and the count store swallows the refusal without a setCount. Patching the badge's copy to match would have left two compilers in place to drift again. Instead the condition has ONE implementation — `@object-ui/core`'s `composeParentScopeFilter`, with the `isMultiValueRelationship` verdict behind it — and both the row query and the badge probe call it. The arity rule itself is still `@objectstack/spec/data`'s `isMultiValueField`, the same predicate the driver executing the query decides on. `RelatedCountStore.fetch` gains an optional trailing `fields` argument, and the `page:tabs` probe effect resolves the child object's schema from the same DataSource the row side reads it from before probing. A caller that cannot see metadata keeps the historical equality wire byte for byte. The store's badge/row parity claim is no longer asserted-and-unenforced: it is held up by a page-level pin that renders a real detail page over a backend which refuses equality the way driver-sql does, and reads the badge digits against the rendered row count at a positive count. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
`tsc` refused the implicit `any` on the map callback (TS7006), which the package's `type-check` reaches because its test config compiles the test files. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
PM review — verified at source, against the merge baseDiff read against R2 — one implementation, not a matching patch ✅
Dependency direction measured, not assumed: R3 — the parity claim is enforced, not asserted ✅This was the ruling-zone item that could have sunk the delivery:
The R4 — Clause-② ✅
Source-text pin sweep (with a lit control)Queried CI36 check runs, 0 pending, 0 failing. A complete read, not a partial one — that discipline was bought with #9169. Changeset
Verdict: land. Clearing Generated by Claude Code |
…arity Brings in objectui#8882 (PR #9184): `composeParentScopeFilter` / `isMultiValueRelationship` in `@object-ui/core`, the seam this branch is the first caller of. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Fixes #8882
The defect, measured
A related list on a
multiple: truerelationship rendered its rows above a tab with no count at all. Measured on a real detail page, the two reads went out as:$filtersent{ task_versions: { $contains: 'tv-1' } }{ task_versions: 'tv-1' }Rows rendered: 2. Badge: null. The driver refuses equality on an array-valued column (
400 INVALID_FILTER), and the count store'scatchswallows that refusal without asetCount, so the store holds no entry and the tab draws nothing.Two independent compilers of one condition: objectui#7299 taught the ROW side to compile by the relationship field's arity, and the badge's copy was not part of that change.
Shape — one implementation, not a matching patch
Patching the badge's copy to agree would have left the second compiler in place to drift again. So the condition now has one implementation:
packages/core/src/utils/parent-scope.ts, exported from@object-ui/core:composeParentScopeFilter(relationshipField, parentId, fields?)— returns the plain MongoDB-style parent scope,$containsor=by arity.isMultiValueRelationship(fields, fieldName)— the verdict on its own, for the raw-URL related-list arm that must know the arity in order to refuse.parentRelationshipFieldDef(fields, fieldName)— the two-shape container reader, moved here fromRelatedList.RelatedList(rows) andRelatedCountStore(badge) both call it.RelatedList's local copy is deleted.The arity rule itself is unchanged and still not local: it is
@objectstack/spec/data'sisMultiValueField, the same predicate the driver executing the query decides on. Measured, since the rule is broader thanmultiple === truein both directions:{type:'multiselect'}and{type:'tags'}answertruewith no flag,{type:'master_detail', multiple:true}answersfalse.Why core, and not "one layer up"
RelatedListcarried a standing warning against putting a local arity rule one layer up. This is not that: the rule is still the spec's, and there is now exactly one caller of it. The warning is preserved at the old site and restated in the new module, now naming the badge as the second reader.Dependency direction was measured, not assumed — no new edge:
@object-ui/corealready depends on@objectstack/specand already exportsmergeFilterNodes, which both sides already import for the other half of this same$filter.@object-ui/componentsdepends on@object-ui/core;@object-ui/plugin-detailpeer-depends on it.components, imported byplugin-detail) also holds, but core is where the sibling half already lives.How the badge gets field metadata
The store's own comment claimed the probe "has no field metadata in hand — four scalars and a filter is the whole input". True of the function, not of its caller: the
page:tabsprobe effect holdsctx.dataSource, the same object the row side callsgetObjectSchemaon. That is what made the shared composer feasible.So
RelatedCountStore.fetchtakes an optional trailingfields, and the probe effect resolves each child object's schema from that DataSource before probing.The two sides make a different trade with the same seam, deliberately:
getObjectSchemawould otherwise render every related list empty. They attempt equality, are refused, and refetch.That asymmetry is pinned rather than left to be rediscovered as a bug (
MEASURED DIFFERENCEbelow).The parity claim is no longer asserted-and-unenforced
related-count-store.tsadvertised badge/row parity as "a property of the code rather than of two implementations agreeing by luck", with the arity gap logged beneath it as a known exception. Both halves of that$filterare now shared, and the claim is held up by a pin that goes red, not by repetition.Tests
New:
packages/app-shell/src/views/RecordDetailView.relatedBadgeArity-8882.test.tsx(5 cases), on the page-level harness its neighbourRecordDetailView.relatedListFilter-4664.test.tsxestablished. The fake backend refuses equality on an array-valued column the waydriver-sqldoes and throws on any filter shape it does not recognise — a permissive evaluator is the one bug that would make the whole file lie.FIXTURE— the backend really refuses equality and really answers membership selectively (2 of 3 rows), in both directions.SUBJECT— badge probe and settled row query carry the same parent condition.SUBJECT— badge digits equal the rendered row count, at a positive count. Two zeros are equal, so parity is only evidence above zero;drawn > 0andbadge !== '0'are asserted separately.MEASURED DIFFERENCE— rows attempt first and refetch; the badge probes exactly once, already correct.LIVE CONTROL— a single-value relationship, correct today and after: both reads stay the byte-identical{ task_version: 'tv-1' }equality object.Before the fix this file read 2 failed / 2 passed: both subjects red (
expected null to be '2'), both controls green.Acceptance notes
Out-of-scope observations, noted and not filed — neither is a reproducible defect or a contract breach, and no queued PR touches these files:
RelatedList's pre-metadata row attempt is a deliberate, documented trade, not a defect; it now has a pin describing it.catchreturns0without asetCount, which is why a refused probe yields no badge rather than a0badge. Unchanged here and correct for this card — a cached0would be worse.🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Generated by Claude Code