Skip to content

fix(components,fields,plugin-list): withhold exists/notExists from the consumers whose dialects cannot express them - #4745

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4736-list-exists-operator-gap
Aug 15, 2026
Merged

fix(components,fields,plugin-list): withhold exists/notExists from the consumers whose dialects cannot express them#4745
yinlianghui merged 1 commit into
mainfrom
claude/issue-4736-list-exists-operator-gap

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4736

Verified at 9847519e3 (the head commit of this branch; every number below was taken at that sha with a clean working tree).

Route taken: B (withhold), after A was measured and found unavailable

The card adjudicated A-then-B: express the operator if the AST vocabulary genuinely can, withhold it only if it cannot. A is not available, and the measurement is unambiguous rather than borderline.

Measured on @objectstack/spec 17.0.0-rc.6, the three at-rest dialects this one dropdown feeds:

vocabularywho persists into itexistence operator?
VALID_AST_OPERATORS (52 members)ListView live grid, via mapOperatornone — zero members matching /exist/
VIEW_FILTER_OPERATORS (19 members)save-as-view + Studio inspectors, via foldFilterGroupToSpecRulesnone — zero members matching /exist/
FILTER_OPERATORS (Mongo criteria)FilterConditionField sharing-rule criteria$exists

The AST set's null-family members are is_null / is_not_null / is_empty / is_not_empty and their squashed spellings — there is no existence token to map onto under any spelling. So "add the mapOperator rows" has no target that is not a semantic claim.

This is not the "expressible on some paths only" case that would have been a needs_decision: the list consumer's dialects express it on no path. The divergence I did find (below) is in the third dialect, which belongs to a different consumer that is out of scope and works correctly today.

What broke, on three surfaces

Reproduced by the new test, which drives the real production function:

  • Live gridmapOperator's default: arm returned the id verbatim, so convertFilterGroupToAST emitted ["some_field","exists","x"] and isFilterAST() returns false. Per filter-operator-ast-parity.test.ts's own header that costs an unfiltered read or a 400 — "Both are a broken query."
  • Save as viewnormalizeFilterOperator('exists') returns 'exists' unchanged and ViewFilterRuleSchema refuses it with invalid_value, so the panel's filter cannot be persisted at all.
  • Dataset inspectorgroupToCondition's OP_TO_MONGO has no row either, and its if (!mop) continue drops the condition silently.

Reachable through pure UI interaction: the operator dropdown's change handler updates operator alone and preserves value, so name equals foo → switch to Is set yields exactly the emitted triple above.

The change

  • @object-ui/componentsexists / notExists join containsCaseInsensitive in OPT_IN_OPERATORS. They stay in defaultOperators (drawable, translated, counted by FILTER_BUILDER_OPERATORS); they are simply no longer offered to a consumer that did not ask.
  • @object-ui/fieldsFILTER_CONDITION_EXTRA_OPERATORS names both, so the sharing-rule criteria builder keeps them. That opt-in is now the only thing keeping $exists authorable, which the fields test pins.
  • @object-ui/plugin-listListView passes an explicit, exported LIST_VIEW_EXTRA_OPERATORS (deliberately empty) rather than omitting the prop, so the parity test has a real handle on what this toolbar offers and a future inline extraOperators cannot widen the dropdown unnoticed.
  • @object-ui/app-shell — comment only. viewFilterFold's note explaining why exists / notExists are in VALUELESS_FILTER_OPERATORS described a rationale this PR changes; the entries stay (the builder still draws them value-less for the criteria widget) but for a different reason now.

Why not map onto isNotNull / isNull

Three independent reasons, all recorded in the code:

  1. The builder already draws isNull / isNotNull as their own rows, so the collapse would put two labels on one wire predicate.
  2. The round trip is lossy — a saved exists reads back through specToBuilderOperator('is_not_null') as isNotNull, rewriting the author's choice on reopen. This is the same refusal view-operator-builder-parity.test.ts already records for is_null mapped onto isEmpty.
  3. The equivalence is not this repo's to declare. @objectstack/spec's own data/index.d.ts records $exists = has-value (!= null) as settled and shipped on formula and driver-memory's reference matcher, while driver-memory's live mingo path and driver-mongodb still read key-presence, both frozen by objectstack#5499 — which is why upstream cannot enrol a $exists conformance row yet either.

The structural requirement: forced by an assertion, not by discipline

objectui has three operator-parity guards and all three sweep spec vocabulary → objectui. None asked whether an id the dropdown draws is an id the consumer can persist, which is the direction that broke.

New: packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts. Its spine is an equality, not a subset check — a subset check is satisfied by a toolbar that offers nothing, and it would let the opposite drift through silently:

  • an offered id that neither dialect expresses ⇒ red, "add it to OPT_IN_OPERATORS";
  • an id both dialects express that is still withheld ⇒ red, "the withdrawal no longer has a reason".

That second direction is what makes the OPT_IN_OPERATORS entry self-retiring: the day the view and AST vocabularies gain an existence operator, this test goes red and says to offer it again. Two anti-tautology guards back it up — the emitted AST node must be non-empty (a row dropped as incomplete yields [], which would otherwise sail through isFilterAST()), and a totality ratchet fails if a new field-type bucket appears that the probe list does not reach.

Verification

All at 9847519e3, working tree clean.

  • Reverse verification — removed the two ids from OPT_IN_OPERATORS on the committed fix, re-ran: 8 failures, exactly the predicted set, the live-grid leg reporting the real emission ["some_field","exists","x"]. Restored with git checkout from the branch and re-ran: 96/96 green. Direction was red, as predicted before running.
  • Union test run: 122 files, 1318 tests passed — plugin-list, components/__tests__, the fields operator test, plugin-view/config/__tests__, data-objectstack's AST parity, and the three app-shell fold tests.
  • Full package sweep (byte-identical tree): components + fields + plugin-list + plugin-view + data-objectstack — 316 files, 3831 tests passed. app-shell filter surface — 57 files, 643 passed / 1 skipped.
  • type-check: components, fields, plugin-list, app-shell — all clean (dependency closures built first, per the fresh-worktree requirement).
  • lint: same four packages, 0 errors; the new test file alone is warning-free.
  • Gates: check-changeset-presence, check-changeset-no-major, check:control-bytes, check:spec-symbols, check:phantom-deps, check:i18n-keys, check:i18n-drift — all exit 0. The filterBuilder.operators.exists / .notExists locale keys stay live (the operators are still drawn, just opt-in) and do not appear in the dead-keys report.

Changeset: patch for the four packages, with the user-visible before/after stated plainly.

Out of scope, filed separately

#4744convertFilterGroupToAST exempts only isEmpty / isNotEmpty from its incomplete-row drop, while the builder renders no value input for six operators. A fresh Is null row therefore carries value: '', is judged unfinished, and is dropped: measured isNull[] versus isEmpty["name","=",null]. That is a value-completeness defect, not a vocabulary one, and isNull / isNotNull stay offered here precisely because both dialects do express them. Not fixed in this PR.


Generated by Claude Code

…o dialect can express them
The shared FilterBuilder offered `exists` / `notExists` to every consumer, but
only one of the three at-rest dialects it feeds has an existence operator.
Measured on @objectstack/spec 17.0.0-rc.6: neither VIEW_FILTER_OPERATORS nor
VALID_AST_OPERATORS contains one under any spelling (zero members matching
/exist/); only the MongoDB-style FieldOperatorsSchema criteria carries $exists.
So on the list toolbar `mapOperator`'s `default:` arm returned the id verbatim
and the query went out as ['name','exists','x'], which isFilterAST() rejects —
an unfiltered read or a 400. Saving the same panel as a view produced a rule
ViewFilterRuleSchema refuses, and the dataset inspector dropped the condition
silently.
The pair moves behind the existing OPT_IN_OPERATORS gate and FilterConditionField
— which writes the criteria dialect — opts in, so $exists stays authorable where
it works. Mapping onto isNotNull/isNull was rejected: those already have their
own rows, the round trip is lossy, and $exists = has-value is not yet settled
across drivers.
Closing the class: objectui's three operator-parity guards all sweep spec
vocabulary to objectui, never the reverse. A new parity test forces the set the
list toolbar offers to EQUAL the set its two dialects can express, in both
directions, so neither an unexpressible offering nor a stale withdrawal can
survive.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnQd8iMMUwXQEV1crFmQiQ
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 15, 2026 10:51pm

Request Review

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT (reviewer of record, session session_01RnQd8iMMUwXQEV1crFmQiQ)

Independently verified, not taken from the report:

  1. The A-unavailability measurement — re-run on the spec sources.ui/view.zod.ts contains zero existence tokens, and every exist hit in data/filter.zod.ts is either prose or the Mongo dialect's $exists schema property — no 'exists'-shaped member in the AST or view operator vocabularies. The A-then-B order was honored: A has no target that is not a semantic claim, and this is the clean "no path" case, not the borderline that would have required needs_decision.
  2. The refusal to collapse onto isNull/isNotNull is correctly grounded — two labels on one wire predicate, lossy round-trip (the same refusal view-operator-builder-parity already records), and the upstream $exists semantics split (has-value vs key-presence, objectstack#5499-frozen) that is not this repo's to settle.
  3. Diff exactness: 8 files, +540/−41; OPT_IN_OPERATORS gains both ids; FILTER_CONDITION_EXTRA_OPERATORS keeps $exists authorable where the dialect carries it; LIST_VIEW_EXTRA_OPERATORS is the explicit exported empty handle so the guard pins the call site, not the abstraction; the app-shell change is comment-only truth maintenance. The third broken surface (dataset inspector's silent continue) being fixed for free by default-off is the right shape.
  4. The structural requirement is met as adjudicated: the new parity test's spine is an equality with both red directions (offer-but-inexpressible AND expressible-but-withheld), which makes the opt-in entry self-retiring the day objectstack#8934-adjacent vocabulary work lands an existence operator. Anti-tautology guards (non-empty emission, totality ratchet) close the two silent-pass routes.
  5. Reverse verification: direction predicted first, 8 predicted failures observed including the real broken emission, restored to 96/96.

Two seat-side notes: my dispatch comment cited a wrong comment id (it pointed at another card's ACCEPT) — the dev read both PM comments and flagged rather than guessing, which is correct handling of a PM slip, acknowledged here. The commit-trailer conflict (repo convention vs harness default) was also flagged rather than silently resolved — the repo-convention choice honoring the no-model-identifier rule is the right one.

Body: Fixes #4736 sole closing keyword; #4744 filed keyword-free. Changeset: four packages patch with the user-visible before/after stated. Follow-up #4744 (valueless-row drop — live user-visible) gets first-touch grading separately. Gates at 9847519e3: awaiting CI — will flip ready + auto-merge once Lint + Type Check + Test shards 1-4 conclude success at this head.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-D_k1wGNa.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)38.46KB10.17KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)490.45KB108.84KB
core (index.js)3.79KB1.52KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)157.05KB43.28KB
fields (index.js)231.21KB57.42KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.89KB1.22KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.86KB12.91KB
plugin-charts (index.js)64.44KB18.28KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)121.90KB31.76KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)239.81KB59.97KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.72KB27.70KB
plugin-gantt (index.js)164.30KB40.02KB
plugin-grid (index.js)192.10KB51.24KB
plugin-kanban (index.js)52.72KB14.54KB
plugin-list (index.js)111.18KB26.93KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.38KB11.09KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)27.64KB9.44KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.28KB0.68KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 15, 2026 23:09
@yinlianghui
yinlianghui added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 616a2a5Aug 15, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4736-list-exists-operator-gap branch August 15, 2026 23:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The list filter builder offers exists / notExists, which ListView.mapOperator cannot express — the query goes out with a raw operator

2 participants

@yinlianghui@claude