Uh oh!
There was an error while loading. Please reload this page.
feat(detail): derived related lists consume the FK's relatedListFilter — AND-composed query, badge counts the same set - #6946
Conversation
The spec key `relatedListFilter` (objectstack#8704 / PR #8955, shipped in
@objectstack/spec 17.1.0) declares a related list's own scope on the
relationship. This repo accepted it at every gate and consumed it nowhere.
- deriveRelatedLists reads it off the FK; RecordDetailView forwards it; the
page synthesizer emits it onto the record:related_list node's EXISTING
`filter` key, where RelatedList already ANDs it with
`{ [referenceField]: parentId }` (objectstack#7118). No new dialect.
- The tab badge counts the same composed predicate: page:tabs reads the node's
filter and the count store composes it with the parent scope through the same
mergeFilterNodes sink the row query uses. Counts cache per scope, so a
filtered and an unfiltered probe never badge each other.
With nothing declared, the node, the row query and the badge probe are
byte-identical to before. Consumption only; no authoring UI.The two boundaries named in the description are now filed, both unassigned and unlabelled for PM triage:
Neither is worked around in this PR. Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB Generated by Claude Code |
✅ 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
|
…rose The doc-version-claims ratchet (Test shard 4/4) flagged the hard-coded version this PR's own diff added: a version literal in prose that nothing in the repo re-verifies. Per the gate's own prescribed remedy (PR #3688 / PR #3698's precedent), delete the literal and point at the truth instead — this package's own `@objectstack/spec` dependency in package.json, which the package manager keeps honest at install time. Fixes the red `Test (shard 4/4)` check on PR #6946; no other files touched.
os-sam
commented
Aug 31, 2026
CI fix for the red Reproduced failure (at head This came from this PR's own diff (+20/-1 to Fix: deleted the literal and pointed at the truth instead, following the gate's own prescribed remedy and the house form from PR #3688 / PR #3698. Minimal, single-file diff: **A third producer writes that same key: the relationship itself.** A `lookup` /
-`master_detail` field may declare `relatedListFilter` (`@objectstack/spec` 17.1.0-— objectstack#8704 / PR #8955), a canonical Query-DSL `FilterCondition` such as+`master_detail` field may declare `relatedListFilter` (added to `@objectstack/spec`+in objectstack#8704 / PR #8955 — see this package's own `@objectstack/spec`+dependency in `package.json` for the version in use), a canonical Query-DSL+`FilterCondition` such asDid not touch Passing run (root-form vitest, this repo's guard refuses All 25 tests in the file pass, including the previously-red one. Pushed sha: PR left in draft, not enqueued, no auto-merge — unchanged per the dispatch constraints. Generated by Claude Code Generated by Claude Code |
✅ 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
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#4664
Auto-derived related lists now consume the field-level
relatedListFilterdeclaration: the list query is the declared filter AND-composed with
{ [referenceField]: parentId }, and the related-list tab badge counts thesame composed predicate, so the badge and the rows answer one question.
Scope guard — condition 2, measured before any code
The card refuses to start unless the pinned
@objectstack/specactuallycarries the key. Measured, not assumed:
package.json"@objectstack/spec": "^17.0.0"17.2.0(30 entries, one resolution)node_modules/.pnpm/@objectstack+spec@17.2.0_.../package.json→17.2.0relatedListFilter?: FilterCondition | undefined, besiderelatedList/relatedListTitle/relatedListColumnsrelatedListFilter— exactly as the card assumed, no driftFilterConditionSchema.optional(), i.e. the canonical Query-DSLFilterCondition(same dialect as a querywhere)The installed key's own
describe()text is the contract this PR implements:Premise holds. No spec pin was touched.
What changed
Five source files, one value, one read site — no new dialect:
app-shell/src/utils/deriveRelatedLists.ts— readsrelatedListFilteroffthe FK onto the derived descriptor. A plain non-empty object is a
declaration; an array, a string,
null, a number,{}are silence. Not atolerant reader: nothing is aliased, coerced or defaulted (AGENTS.md #0.1),
the same declared-vs-absent discrimination
relatedListTitleandrelatedListColumnsalready do next to it.app-shell/src/views/RecordDetailView.tsx— forwards it into the pagesynthesizer's
relatedshape.plugin-detail/src/synth/buildDefaultPageSchema.ts— emits it onto therecord:related_listnode's existingfilterkey.That key is where the reuse is honest and where it stops.
filterhas had aread site since objectstack#7118:
RelatedListlowers it through the repo's onefilter sink and ANDs it with the parent scope, wrapping both sources under a
single
andrather than letting either replace the other. The derived producerlands on the same key as the component-level authored
filterand as adataSourcebinding's composed filter — three producers, one read site, onelowering. Nothing was added to the list path; it already did the right thing
with a value it never received.
The badge half needed new code, because the tab strip's probe read only
objectNameandrelationshipFieldoff the node it was badging:components/src/renderers/layout/containers.tsx— thepage:tabscountprobe now also reads the node's
filter.components/src/hooks/related-count-store.ts— composes it with the parentscope through the same
mergeFilterNodessink the row query uses, so thetwo
$filtervalues are one composition used twice rather than twoimplementations that agree by luck. Cache identity gains the scope, placed
INSIDE the relField segment so
invalidate's two structural reads on thesekeys (
startsWith(object::),endsWith(::parentId)) keep working — a scopesegment appended after the parent id would have orphaned the entry, and its
failure mode is a badge that never refreshes after a write, with no error to
notice.
With nothing declared, the synthesized node, the row query and the badge probe
are byte-identical to before (
{ [referenceField]: parentId }, the untouchedMongoDB-style object — not a freshly lowered AST that means the same thing).
Consumption only. No authoring UI for the filter, as the card requires.
Verification
Every exit code below was captured before any pipe, and each gate is quoted by
the line the gate itself printed.
Proving the AND, not just the filter. The page test's fake backend
EVALUATES the composed predicate over a four-row fixture built so each conjunct
alone admits a row the other excludes:
Parent scope alone answers 2, the declared filter alone answers 2, the
conjunction answers 1 — so dropping either conjunct changes the number the test
reads, in both directions. Those three numbers are asserted from the fixture
itself, so a later edit that flattens it into a single-condition fixture fails
loudly instead of quietly weakening every assertion above it. The evaluator
THROWS on any filter shape it does not recognise: a permissive evaluator would
answer "all rows" for a malformed filter, which is exactly what the
counter-probes would read as a pass.
Proving badge and list agree, in one test, with the unfiltered count
differing from the filtered one: the list renders
Live Itemand neitherArchived Item(excluded by the declared conjunct) norOther Parent Item(excluded by the parent conjunct), while the tab badge reads
1. Theparent-scoped unfiltered count is 2, so a badge ignoring the filter reads
2there.
Ablation — direction predicted in writing before either run. These tests
resolve through the root
vitest.config.mtsalias table, which maps every@object-ui/*specifier topackages/*/src; there is nodist/seam for amutation to be lost behind and therefore no rebuild leg. Each leg proved the
mutation on disk (anchored count of the injected marker AND of the deleted text,
plus
git hash-objectdiffering from the HEAD blob) and proved the restore bothways (
git diff HEADempty AND hash equal to the HEAD blob), undertrap ... EXIT INT TERMwith absolute paths fromgit rev-parse --show-topleveland
git checkout HEAD -- ABSPATH, never bare.Leg A — drop the filter from the BADGE path alone (
containers.tsx).Predicted: the two badge legs red, the row leg green, the other three files
green. Observed, exactly:
The second line is the defect this card exists to prevent, reproduced at
fixture scale: the badge said 2 above a list showing 1.
Leg B — the converse: drop it from the LIST path alone
(
record-related-list.tsx). Predicted: the row leg red, the badge-equality legred as the mirror image of A, parity red the other way round, the other three
files green. Observed, exactly:
Gates, all at the final commit
729d29c6c:file that reads one of the five changed modules, by grep, rather than by
guess): before — 110 files / 1559 tests passed, at BASE
40c479af2in acomparison worktree; after — 114 files / 1586 tests passed. Delta +4
files / +27 tests, exactly the four test files this PR adds. No pre-existing
file or test count moved. The reported file count (114) equals the derived
list's own count (114) — the sanity check against a run that silently
substitutes another package's suite.
type-check(hyphenated) for all three touched packages, script name echoedby pnpm as
tsc --noEmit && tsc -p tsconfig.test.json, all threeDone.The
.test.tsfiles are genuinely inside those programs, not excluded:tsc -p tsconfig.test.json --listFilesnames all four new test files(app-shell 2, components 1, plugin-detail 1).
pnpm exec eslint ., plain form (no--no-inline-config), whole repo:exit 0,
✖ 11554 problems (0 errors, 11554 warnings)— 0 errors; thewarnings are the repo-wide pre-existing
no-explicit-anypopulation.node scripts/check-changeset-presence.mjs: exit 0 —✅ 9 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s).node scripts/check-changeset-no-major.mjs: exit 0 —✅ No changeset declares a 'major' bump.The changeset isminoron thethree packages and states the new consumption in words.
Boundaries named rather than forced
record:reference_railrenders its own total-count badge from a fixed
{ [relationshipField]: parentId }query, so on a page whose FK declares afilter the Related tab badge and the rail card for the same child disagree.
It is not fixable here:
ReferenceRailEntrySchemaisstrictObjectwith nofilterkey, and the spec's own guidance on that shape says the rail"honours no per-entry
filter" and that "record:related_listis thecomponent whose
filteris real; if the rail is ever granted one, this entryshape is where it gets declared and enforced." Granting it is an upstream
spec decision, not this card. Filed separately.
relatedListFilteris typed
FilterCondition, whose type explicitly declares$and/$or/$not— butconvertFiltersToASTin@object-ui/corehas no combinatorbranch:
{ $or: [...] }lowers to['$or','=',[...]](a leaf naming a field$or) and{ $not: {...} }throwsFilterOperatorErrornaming a nonsenseoperator. Pre-existing on every consumer of that sink since
objectstack#7118; this key newly makes it reachable by an author following
the spec's own type. Fixing it means changing the shared sink for every
caller, which is its own card. Filed separately; not patched around here,
because a renderer-side workaround is exactly the second de-facto contract
#0.1 forbids.
⛔ Left in draft, not enqueued, no auto-merge — the PM lands it.
Generated by Claude Code
Generated by Claude Code