feat(app-shell): give the drill URL dialect an is-null operator, so an empty-bucket drill opened "in list" stops landing on a superset - #9506
Merged
Conversation
The drill escape hatch (OpenInListButton, drillDown.target: navigate) reaches
the host's openRecordList, which serializes the drill filter into filter[...]
search params for the ADR-0055 bare data surface. That dialect had equality plus
four range bounds and nothing else, so an empty-bucket drill's condition simply
vanished: { stage: 'won', owner: { $null: true } } and the bare null it replaced
both produced filter[stage]=won, byte for byte. The surviving condition was the
NON-empty one, so escalating an empty bucket to the list page returned a
superset, silently, with chips that showed only the conditions that survived.
drillUrlFilters now carries the operator on both halves of its one module, as
NULL_FILTER: the write side emits filter[<field>][null]=true for { $null: true },
the read side parses it back to [field, 'is_null', true] — the same triple the
other two drill consumers already produce — and the chip grouper renders "is
null" where it previously fell to the "= <value>" default and showed a bare
true.
The value is a flag, not data. Only "true" spells the condition: this dialect
cannot write "is not null", so [null]=false is dropped like an unknown suffix
rather than answered at an operator with no producer, and { $null: false } is
dropped on the write side for the same reason. The range maps are untouched on
purpose — is_null is already a canonical ViewFilterRule word, and ObjectDataPage
inverts URL_FILTER_OPS to bridge triples to the spec's ALIAS spelling, so an
entry there would have made "Save as view" drop the condition.
The objectui#9085 boundary pin, which recorded this gap as open and was designed
to go red the day someone closed it, is updated to the new byte-identity rather
than deleted.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
Contributor
✅ 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
|
Round 2 of the drill URL is-null operator. The chip arm landed with its text finished inside groupFilterChips as the literal "is null". A user-facing literal in a renderer is unlocalized for every reader on every locale, and "make it English" answers only half of that -- the defect survives verbatim, in whatever language it was written in. The grouper now hands out an i18n KEY for that arm instead of finished text (FilterChip.textKey) and ObjectDataPage resolves it at the same half-chip seam that already draws the field name through fieldLabel. The key is the filter builder's existing operator label, already defined and translated in all ten packs and already policed by that family's locale-parity pin, so nothing new is authored and one operator label keeps one spelling across the product. The range and equality arms are untouched and still finish their own text: a comparand is the user's own data, which no catalogue can translate and which reads the same in every locale. That split is the type's documented contract, not a convenience. Passed bare, with no inline defaultValue: a default here would be an unwatched second English spelling that hides a pack miss, which this console deleted everywhere else for that reason. Pinned by a real render of the page under a non-English locale -- English cannot tell a hardcoded literal from a resolved pack value, since both render the same pixels -- with two controls: the packs are asserted to differ live, and the text is asserted not to contain the key's own prefix, which is what i18next renders when a key fails to resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
Contributor
✅ 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
|
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.
Fixes #9159
The drill escape hatch —
OpenInListButtonanddrillDown.target: navigate— reaches the host'sopenRecordList, which serializes the drill filter intofilter[...]search params for the ADR-0055 bare data surface. That dialect had equality plus four range bounds and nothing else, so an empty-bucket drill's condition simply vanished and the escalated list page returned a superset, silently, with chips that showed only the conditions that survived. This adds the missing operator, as the card enumerated it: a write side, a read side, and a chip.The three coordinated parts
All three live in
drillUrlFilters— the one module that holds both halves of this contract, so they cannot drift apart — behind one exported constant,NULL_FILTER:{ $null: true }produced nothingfilter[FIELD][null]=true[FIELD, 'is_null', true]= VALUE, i.e. a baretrue(
FIELDandVALUEare spelled as words rather than in angle brackets: GitHub's body sanitizer eats tag-shaped fragments, backticks and fenced blocks included.)The read-side triple is the same one the other two consumers of
buildDatasetDrillFilteralready produce, becauseconvertFiltersToASTlowers{ $null: true }to exactly it. The write side emits the flag beside any range bound on the same object rather than instead of it, for the same reason: that converter emits both conditions for that input, and the two drill sinks agreeing is the point.The chip is drawn from the locale packs, not finished in the renderer
Round 2, on this seat's ruling B. The chip arm first landed with its text finished inside
groupFilterChipsas the literalis null. A user-facing literal in a renderer is unlocalized for every reader on every locale, and "make it English" answers only half of that, so the arm now hands out an i18n key (FilterChip.textKey) andObjectDataPageresolves it at the same half-chip seam that already draws the field name throughfieldLabel.The key is
filterBuilder.operators.isNull— the filter builder's existing operator label, already defined and translated in all ten packs and already policed by that family's locale-parity pin. Nothing new is authored, and one operator label keeps one spelling across the product rather than two. Its catalogue wording reads correctly mid-chip in the packs checked (Is null,为 null,Ist null,null である,Равно null), so no wording report is owed.It is passed bare, with no inline
defaultValue: a default there would be an unwatched second English spelling that hides a pack miss, which is the pattern this console deleted everywhere else for exactly that reason.The range and equality arms are untouched and still finish their own text — a comparand is the user's own data, which no catalogue can translate and which reads the same in every locale. That split is the chip type's documented contract, and the equality arm is pinned as a live control so a change that routed every chip through the translator would go red.
Pinned by a real render, not by the key.
ObjectDataPage.filterChipI18n-9159.test.tsxmounts the page under a realI18nProviderat the empty-bucket drill URL and reads the chip's own text node. English cannot tell the two worlds apart — a hardcoded literal and a resolvedenpack value render the same pixels — so the assertion is underzh, with two controls that remove the ways it could pass vacuously: the packs are asserted to differ live, and the text is asserted not to contain the key's own prefix, which is what i18next renders when a key fails to resolve. A pack that loses this key is therefore visible here instead of silently falling back to English.Round-2 readings
Ablation from the committed state, mutating only the render seam back to the round-1 literal, with the replacement proved on disk before the run and the restore proved after:
The one that stayed green is the equality control, which never reaches this arm — which is what makes it a control rather than coverage. Restored, then green again; and at head
2dacd73the package scope reads:Typecheck (both tsconfigs),
eslint .over the package (0 errors), and the gates — includingcheck:i18n-keys— are green at that head.The answer for
filter[FIELD][null]=false, pinnedThe value is a flag, not data. Only
truespells the condition.[null]=falseis not a second operator. This dialect cannot WRITE "is not null" — nothing here emits it — so the read side drops that param exactly as it drops an unknown suffix: never downgraded tois_null false, never to an equality against the string"false". A read-side-only operator with no producer would be a second contract, not a repair.{ $null: false }is dropped on the write side for the same reason, degrading to a superset exactly as any other unspellable operator already does.filter[owner]=round-trips to no condition at all. The flag's value is a non-empty literal for that reason.Why the range maps are untouched — this one is load-bearing
is_nullis already a canonicalViewFilterRuleoperator word, andObjectDataPageinvertsURL_FILTER_OPSto bridge a triple's operator to the spec's alias spelling. Had the flag been added there, a saved view would have received the aliasnull, whichnormalizeFilterOperatorpasses through verbatim and the rule schema then refuses — "Save as view" would silently drop the condition. Keeping the flag out of the range vocabulary is what keeps that path correct, andObjectDataPage.saveAsViewFilterFold.test.tsnow pins the fold for the new operator (operator: 'is_null', gate green) so the reasoning is executable rather than remembered.How the objectui#9085 boundary pin was updated
drillEmptyBucketNavHost-9085.test.tsrecorded this gap as open and was written to go red the day someone added the operator. It is updated, not deleted, exactly where that card said it would break, carrying objectui#9159 and the reason at the assertion:NULL_FILTERby content.nullVALUE stays "no condition", which is what a producer writes when it has nothing to say about the field.Both CONTROL cases are untouched and still green.
Verification — readings from real run output
The pins fail before the change. New pins run against the unmodified module, before any source edit existed:
Ablation from the committed state, mutation proved on disk before the run and restoration proved after (
git checkout HEAD -- PATH, thengit diff HEADempty and the on-disk hash byte-identical to the HEAD blob):They pass after — the four touched test files, re-run at this PR's head commit on the restored tree:
Package scope.
pnpm exec vitest run packages/app-shell/src/views/(repo-root invocation, the form AGENTS.md requires):That run was taken on a tree differing from this PR's head only by the addition of
.changeset/9159-drill-url-isnull-operator.md; every consumer of this module lives undersrc/views/, so the narrowing to that path excludes nothing that reads it. The rest ofapp-shelland the other packages are CI's run, not a local one.Also green:
tsc --noEmitplustsc -p tsconfig.test.jsonfor@object-ui/app-shell(exit 0, after building the dependency closure — a staledist/*.d.tslies in both directions);eslint .over the package (exit 0, 0 errors, 3023 pre-existing warnings);node scripts/check-changeset-presence.mjs,check-changeset-no-major,check:control-bytes,check:new-line-citations,check:changeset-claims,check:test-path-roots— all exit 0.Clause-2
Declared
noby the dispatching seat, and it holds on inspection:drillUrlFiltersis not re-exported frompackages/app-shell/src/index.tsnor from the views barrel, and the package'sexportsmap publishes only.and./styles.css, soNULL_FILTERis reachable only inside the package. No published type moved and nothing@objectstack/specdeclares was touched — the spec side was only read (is_nullis already one of its canonical operator words).Acceptance notes
Out of scope, recorded here rather than repaired in this PR:
parseUrlFilterTriplesreads its operator suffix off a plain object withURL_FILTER_OPS[suffix], so a suffix naming anObject.prototypemember resolves through the prototype chain and passes the truthiness test. Measured with a standalone probe of the pre-change reader:filter[amount][constructor]=1,filter[amount][toString]=1andfilter[amount][hasOwnProperty]=1each emit a triple whose operator is a function, whilefilter[amount][nope]=1correctly emits nothing. That contradicts this function's own stated contract ("an unknown operator suffix is ignored"). The new is-null arm is a strict equality against a literal suffix and does not participate. Dedupe words:parseUrlFilterTriples,URL_FILTER_OPS, prototype,constructor, operator suffix.convertFiltersToASTalso lowers{ $exists: false }to is-null, and{ $null: false }/{ $exists: true }tois_not_null. This URL dialect spells none of those, so a drill carrying one still vanishes on the escape hatch. Nothing on this path emits them today —buildDatasetDrillFilterwrites{ $null: true }— but an authored widget filter can. Recorded in the module's own prose as a known unspelled synonym. Dedupe words:serializeDrillFilterParams,$exists,is_not_null, drill URL, unspelled operator.is nullis the first natural-language string ingroupFilterChips; its siblings (= VALUE,start arrow end) are symbol-only and language-neutral. Routing it through@object-ui/i18nwould mean changing what the grouper returns, which is wider than this card. The successor who touches it is whoever next edits the chip row inObjectDataPage— an existingoperators.isNullkey is already in the English catalogue if that is wanted.Draft on purpose, no auto-merge, not enqueued.
Authored with Claude Code in session
session_011QreXiyMEqKLN4U5daMPVa(written as prose because an edited body loses the link form of that reference).Generated by Claude Code
Generated by Claude Code