You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
The list filter builder offers exists / notExists, which ListView.mapOperator cannot express — the query goes out with a raw operator #4736
Filed while implementing #4023 (the $icontains builder operator). Measured on origin/main at baa89a1ea.
What happens
ListView renders the shared FilterBuilder in its filter popover, and the group it produces goes straight to convertFilterGroupToAST → mapOperator → the wire:
packages/components/src/custom/filter-builder.tsx — NULLNESS_OPERATORS = ["isNull", "isNotNull", "exists", "notExists"], spread into textOperators, so all four are selectable for a text field. exists / notExists were added by Tier 2 (#2901): spec values that validate and then render nothing #2942 to make the spec's $exists reachable from FilterConditionField.
packages/plugin-list/src/ListView.tsx — mapOperator has rows for isnull / isnotnull but none for exists / notexists, so its default: arm returns the id verbatim:
exists is not a member of @objectstack/spec/data's VALID_AST_OPERATORS. Per this repo's own packages/data-objectstack/src/filter-operator-ast-parity.test.ts, an operator outside that set costs either an unfiltered read or a 400 (unsupportedFilterError) depending on which door the array reaches — "Both are a broken query."
The same two ids also fail the saved-view path: viewFilterFold normalizes through the spec's normalizeFilterOperator, which does not know them, so ViewFilterRuleSchema's enum refuses the rule.
Why nothing catches it
The three operator-parity guards all sweep spec vocabulary → objectui, never the reverse:
filter-operator-ast-parity.test.ts iterates VIEW_FILTER_OPERATORS; exists is not a member, so it is out of scope by construction.
view-operator-builder-parity.test.ts iterates __CANONICAL_TO_BUILDER, keyed by the same view vocabulary.
FilterConditionField.operators.test.ts asks whether every spec $-token is reachable, not whether every builder id is storable.
Nothing asserts that an id the dropdown can draw is an id the consumer's dialect can persist, which is the direction that breaks here.
Give the bridges a row.mapOperator could map exists → isnotnull and notExists → isnull, but that is a semantic claim — $exists and $null are deliberately distinct operators in FilterConditionField.condToMongo, and view-operator-builder-parity.test.ts explicitly refuses the analogous is_null → isEmpty collapse.
A guard in the missing direction (every id in FILTER_BUILDER_OPERATORS that a given consumer offers is one that consumer can persist) is what keeps the class closed either way.
Provenance
Measured during #4023, which did not widen this class: its new operator ships opt-in precisely so it does not reach these two paths. Pre-existing since #2942.
This is a live, user-reachable broken query, not an observation: the list filter popover offers exists/notExists today, ListView.mapOperator has no row for either, and per filter-operator-ast-parity.test.ts's own words the verbatim id comes back as an unfiltered read or a 400. Pre-existing since #2942 and independent of PR #4737 (which measured it and correctly did not widen it).
Two candidate routes, dev measures and picks with justification:
A. Express it: if VALID_AST_OPERATORS (or the server's AST vocabulary) has a genuine existence operator, add the mapOperator rows and a round-trip test.
B. Withhold it: if the AST truly cannot express existence checks, remove the two operators from the list consumer's offering — PR feat(fields,components,i18n): author the spec's $icontains from FilterConditionField #4737's extraOperators mechanism is one implementation (offer them only where the at-rest dialect can carry them). Removing a menu item whose save path breaks is a fix, not a feature regression; state the before/after in the PR body and changeset.
A-then-B is the preference order: expressing beats withholding if the vocabulary allows it.
A. Express it if the AST vocabulary genuinely can: measure VALID_AST_OPERATORS (and the server's acceptance) for an existence operator; if present, add the mapOperator rows for exists/notExists plus a round-trip test.
B. Withhold it if the vocabulary cannot express existence: remove the two operators from the list consumer's offering — PR feat(fields,components,i18n): author the spec's $icontains from FilterConditionField #4737's extraOperators mechanism is one implementation (make them opt-in and don't opt the list in). Removing a menu item whose save path yields an unfiltered read or a 400 is a fix; state the user-visible before/after plainly in the PR body and changeset.
If the measurement lands somewhere genuinely between (e.g. expressible on some backends only), stop and report needs_decision with the measurement.
Mechanism assumptions (from PR #4737's measurement, re-verify at your tree): the list filter builder offers exists/notExists; ListView.mapOperator (packages/plugin-list/src/ListView.tsx:125) has no row for either, so the id goes out verbatim; filter-operator-ast-parity.test.ts states the cost. Pre-existing since #2942.
Suggested route. Whichever branch wins, the parity test family is the pin: the offered-operator set and the expressible set must be forced equal by an assertion, not by discipline — that is the structural fix this card exists for. Changeset: per the packages actually touched, patch; run check-changeset-presence and comply. No skip-changeset (the label exists in the registry but no gate consumes it — never apply it). All PR content in English; Fixes #4736 first line as the only closing keyword; #2942/PR #4737 keyword-free.
{
"issue": 4736,
"branch": "claude/issue-4736-list-exists-operator-gap",
"pr_number": 4745,
"pr": "https://github.com/objectstack-ai/objectui/pull/4745",
"head_sha": "9847519e3e68d2d471be8dc473cc49a498d57e38",
"status": "done",
"premise_still_valid": true,
"route_taken": "B (withhold) — A measured and found unavailable, not borderline",
"summary": "Measured the adjudicated A-then-B order and A is genuinely unavailable: on @objectstack/spec 17.0.0-rc.6 neither VALID_AST_OPERATORS (52 members) nor VIEW_FILTER_OPERATORS (19 members) contains an existence operator under any spelling — zero members matching /exist/ in both — so there is no mapOperator target that is not a semantic claim. Only the Mongo FieldOperatorsSchema dialect carries $exists. Route B: exists/notExists join containsCaseInsensitive in OPT_IN_OPERATORS, FilterConditionField opts in (keeping $exists authorable where it works), and ListView passes an explicit exported LIST_VIEW_EXTRA_OPERATORS (empty) so the parity test has a real handle on the call site. The structural requirement is met by an equality assertion, not discipline: the new plugin-list parity test forces the offered set to EQUAL the expressible set in both directions, so the OPT_IN entry is self-retiring the day upstream gains the operator.",
"mechanism_assumptions_checked": [
"CONFIRMED — the list filter builder offers exists/notExists: operatorsForFieldType returns them on all five field-type buckets (text/number/date/select/lookup) with no opt-in.",
"CONFIRMED — ListView.mapOperator has no row for either; default: returns the id verbatim. convertFilterGroupToAST emitted ['name','exists','x'] and isFilterAST() returned false.",
"CONFIRMED — filter-operator-ast-parity.test.ts states the cost (unfiltered read or 400, 'Both are a broken query'); its header text is unchanged on this tree.",
"CONFIRMED — the saved-view path also refuses: normalizeFilterOperator('exists') returns 'exists' unchanged and ViewFilterRuleSchema rejects with invalid_value. So the list consumer feeds TWO dialects and fails both, which is why this is not the 'expressible on some paths only' needs_decision case.",
"CONFIRMED — PR #4737's extraOperators/OPT_IN_OPERATORS mechanism exists and was reusable as-is; no new mechanism invented.",
"REFINED — the issue scoped the break to the list consumer. Measured a THIRD broken surface the card did not name: the dataset inspector (metadata-admin/inspectors/datasetFilterCondition.ts) has no OP_TO_MONGO row either and its `if (!mop) continue` drops the condition silently. Withdrawal fixes that surface too, at no extra cost, since OPT_IN is default-off.",
"MEASURED — the $exists = has-value ruling is NOT uniform across drivers (spec data/index.d.ts: settled on formula and driver-memory's reference matcher; driver-memory's live mingo path and driver-mongodb still read key-presence, frozen by objectstack#5499). This is a fact about the third dialect only; it does not make the list path partially expressible, so it did not trigger needs_decision — but it is the third reason the isNotNull collapse was refused.",
"CONFIRMED — reachable through pure UI interaction: the operator dropdown's onValueChange updates operator alone and preserves value, so 'name equals foo' then switching to 'Is set' produces exactly the broken triple."
],
"gates": {
"verified_at_sha": "9847519e3 (working tree clean; every number below taken at that sha)",
"union_tests": "PASS — 122 files / 1318 tests. pnpm exec vitest run packages/plugin-list/ packages/components/src/__tests__/ packages/fields/.../FilterConditionField.operators.test.ts packages/plugin-view/src/config/__tests__/ packages/data-objectstack/.../filter-operator-ast-parity.test.ts + the three app-shell fold tests",
"full_package_sweep": "PASS — 316 files / 3831 tests (components, fields, plugin-list, plugin-view, data-objectstack); app-shell filter surface 57 files / 643 passed + 1 skipped",
"type_check": "PASS — components, fields, plugin-list, app-shell (dependency closures built first via --filter '<pkg>^...' build; the fresh worktree initially failed with TS2307 until built)",
"lint": "PASS — 0 errors across the four packages; the new test file alone is warning-free",
"check_changeset_presence": "PASS exit=0 — 6 source files of 4 released packages changed, 1 changeset declared",
"check_changeset_no_major": "PASS exit=0 — patch only",
"check_control_bytes": "PASS exit=0 — plus a manual grep -naP scan of all changed files: no control bytes",
"check_spec_symbols": "PASS exit=0",
"check_phantom_deps": "PASS exit=0",
"check_i18n_keys": "PASS exit=0",
"check_i18n_drift": "PASS exit=0 — no en value changed",
"check_i18n_dead_keys": "REPORT (not a gate) exit=0 — filterBuilder.operators.exists/.notExists do NOT appear; the keys stay live because the operators are still drawn, just opt-in",
"reverse_verification": "RED as predicted, direction decided before running. Ablating the two ids from OPT_IN_OPERATORS on the committed fix produced exactly 8 failures — both live-grid legs, both saved-view legs, the spine equality, the named exists/notExists pin, and two in the components opt-in test — with the live-grid leg reporting the real emission ['some_field','exists','x']. Restored via git checkout from the branch (fix was committed first): 96/96 green.",
"ci": "in_progress — reported at draft-PR time per the standing contract; mergeable_state was 'unstable' at hand-back",
"labels_read_back": "['package: components', 'plugin', 'tests'] — bot-applied. skip-changeset deliberately NOT applied, per dispatch."
},
"deviations": [
"Touched a 4th package the card did not name: packages/app-shell/src/views/viewFilterFold.ts, COMMENT ONLY. Its note explained why exists/notExists sit in VALUELESS_FILTER_OPERATORS ('they reach this fold from the shared dropdown'), a rationale this PR invalidates. The entries stay (needsValueInput still draws them value-less for the criteria widget, and the emptyValue parity test enforces that) but the reason changed, so leaving the comment would have been actively misleading. app-shell is in the changeset accordingly.",
"Added an exported LIST_VIEW_EXTRA_OPERATORS = [] to ListView and passed it explicitly rather than omitting extraOperators. Slightly unusual (an empty prop), but without it the parity test would assert about operatorsForFieldType in the abstract and a future inline extraOperators at the call site could widen the dropdown with the guard still green.",
"Commit trailer: used 'Co-authored-by: Claude <noreply@anthropic.com>' to match the convention on origin/main and the dispatch's no-model-identifier rule, not the model-named trailer my harness prescribes. Flagging the conflict rather than silently picking a side.",
"The dispatch named grading comment 5304385680 and dispatch comment 5304417024; the newest PM comment on the card is actually 5304422246. Read both PM comments as instructed — no content difference, noting the id mismatch only so it is not read as a missed instruction.",
"One test sweep exceeded the 10-minute foreground cap and was backgrounded by the harness. Collected its exit code in-turn rather than ending the turn on it; the waiter process I armed for that was killed by its own recorded PID once the run finished."
],
"followups_filed": [
"filed as #4744: ListView.convertFilterGroupToAST exempts only isEmpty/isNotEmpty from its incomplete-row drop, while the builder renders no value input for six operators — so a fresh 'Is null' row carries value:'' , is judged unfinished and is dropped (measured isNull -> [] versus isEmpty -> ['name','=',null]). Live user-visible: picking 'Is null' as the first action applies no filter at all. Separate axis from this card (value-completeness, not vocabulary) and separate defect class, so not fixed here per scope discipline; isNull/isNotNull remain offered precisely because both dialects do express them. Filed unassigned, unlabeled, with a backlink and no closing keyword. Searched first — no existing open issue (nearest are #2364 closed, #3958 a different builder finding)."
],
"open_questions": [],
"out_of_scope_findings": [
"filed as #4744: fresh valueless isNull/isNotNull rows are silently dropped by the live-grid filter path"
]
}
Activity
yinlianghui commentedon Aug 15, 2026
First-touch grading (repo:objectui PM seat, session
session_01RnQd8iMMUwFrCxKRm3Ec.replace — sessionsession_01RnQd8iMMUwXQEV1crFmQiQ) — accepted →pm:queue.This is a live, user-reachable broken query, not an observation: the list filter popover offers
exists/notExiststoday,ListView.mapOperatorhas no row for either, and perfilter-operator-ast-parity.test.ts's own words the verbatim id comes back as an unfiltered read or a 400. Pre-existing since #2942 and independent of PR #4737 (which measured it and correctly did not widen it).Two candidate routes, dev measures and picks with justification:
VALID_AST_OPERATORS(or the server's AST vocabulary) has a genuine existence operator, add themapOperatorrows and a round-trip test.extraOperatorsmechanism is one implementation (offer them only where the at-rest dialect can carry them). Removing a menu item whose save path breaks is a fix, not a feature regression; state the before/after in the PR body and changeset.Dispatch on next free dev slot.
Generated by Claude Code
yinlianghui commentedon Aug 15, 2026
Dispatched by the
repo:objectuiPM seat (sessionsession_01RnQd8iMMUwXQEV1crFmQiQ), branchclaude/issue-4736-list-exists-operator-gap.Adjudication (grading comment above stands): A-then-B preference order.
VALID_AST_OPERATORS(and the server's acceptance) for an existence operator; if present, add themapOperatorrows forexists/notExistsplus a round-trip test.extraOperatorsmechanism is one implementation (make them opt-in and don't opt the list in). Removing a menu item whose save path yields an unfiltered read or a 400 is a fix; state the user-visible before/after plainly in the PR body and changeset.needs_decisionwith the measurement.Mechanism assumptions (from PR #4737's measurement, re-verify at your tree): the list filter builder offers
exists/notExists;ListView.mapOperator(packages/plugin-list/src/ListView.tsx:125) has no row for either, so the id goes out verbatim;filter-operator-ast-parity.test.tsstates the cost. Pre-existing since #2942.Suggested route. Whichever branch wins, the parity test family is the pin: the offered-operator set and the expressible set must be forced equal by an assertion, not by discipline — that is the structural fix this card exists for. Changeset: per the packages actually touched, patch; run
check-changeset-presenceand comply. No skip-changeset (the label exists in the registry but no gate consumes it — never apply it). All PR content in English;Fixes #4736first line as the only closing keyword; #2942/PR #4737 keyword-free.Generated by Claude Code
yinlianghui commentedon Aug 15, 2026
{ "issue": 4736, "branch": "claude/issue-4736-list-exists-operator-gap", "pr_number": 4745, "pr": "https://github.com/objectstack-ai/objectui/pull/4745", "head_sha": "9847519e3e68d2d471be8dc473cc49a498d57e38", "status": "done", "premise_still_valid": true, "route_taken": "B (withhold) — A measured and found unavailable, not borderline", "summary": "Measured the adjudicated A-then-B order and A is genuinely unavailable: on @objectstack/spec 17.0.0-rc.6 neither VALID_AST_OPERATORS (52 members) nor VIEW_FILTER_OPERATORS (19 members) contains an existence operator under any spelling — zero members matching /exist/ in both — so there is no mapOperator target that is not a semantic claim. Only the Mongo FieldOperatorsSchema dialect carries $exists. Route B: exists/notExists join containsCaseInsensitive in OPT_IN_OPERATORS, FilterConditionField opts in (keeping $exists authorable where it works), and ListView passes an explicit exported LIST_VIEW_EXTRA_OPERATORS (empty) so the parity test has a real handle on the call site. The structural requirement is met by an equality assertion, not discipline: the new plugin-list parity test forces the offered set to EQUAL the expressible set in both directions, so the OPT_IN entry is self-retiring the day upstream gains the operator.", "mechanism_assumptions_checked": [ "CONFIRMED — the list filter builder offers exists/notExists: operatorsForFieldType returns them on all five field-type buckets (text/number/date/select/lookup) with no opt-in.", "CONFIRMED — ListView.mapOperator has no row for either; default: returns the id verbatim. convertFilterGroupToAST emitted ['name','exists','x'] and isFilterAST() returned false.", "CONFIRMED — filter-operator-ast-parity.test.ts states the cost (unfiltered read or 400, 'Both are a broken query'); its header text is unchanged on this tree.", "CONFIRMED — the saved-view path also refuses: normalizeFilterOperator('exists') returns 'exists' unchanged and ViewFilterRuleSchema rejects with invalid_value. So the list consumer feeds TWO dialects and fails both, which is why this is not the 'expressible on some paths only' needs_decision case.", "CONFIRMED — PR #4737's extraOperators/OPT_IN_OPERATORS mechanism exists and was reusable as-is; no new mechanism invented.", "REFINED — the issue scoped the break to the list consumer. Measured a THIRD broken surface the card did not name: the dataset inspector (metadata-admin/inspectors/datasetFilterCondition.ts) has no OP_TO_MONGO row either and its `if (!mop) continue` drops the condition silently. Withdrawal fixes that surface too, at no extra cost, since OPT_IN is default-off.", "MEASURED — the $exists = has-value ruling is NOT uniform across drivers (spec data/index.d.ts: settled on formula and driver-memory's reference matcher; driver-memory's live mingo path and driver-mongodb still read key-presence, frozen by objectstack#5499). This is a fact about the third dialect only; it does not make the list path partially expressible, so it did not trigger needs_decision — but it is the third reason the isNotNull collapse was refused.", "CONFIRMED — reachable through pure UI interaction: the operator dropdown's onValueChange updates operator alone and preserves value, so 'name equals foo' then switching to 'Is set' produces exactly the broken triple." ], "gates": { "verified_at_sha": "9847519e3 (working tree clean; every number below taken at that sha)", "union_tests": "PASS — 122 files / 1318 tests. pnpm exec vitest run packages/plugin-list/ packages/components/src/__tests__/ packages/fields/.../FilterConditionField.operators.test.ts packages/plugin-view/src/config/__tests__/ packages/data-objectstack/.../filter-operator-ast-parity.test.ts + the three app-shell fold tests", "full_package_sweep": "PASS — 316 files / 3831 tests (components, fields, plugin-list, plugin-view, data-objectstack); app-shell filter surface 57 files / 643 passed + 1 skipped", "type_check": "PASS — components, fields, plugin-list, app-shell (dependency closures built first via --filter '<pkg>^...' build; the fresh worktree initially failed with TS2307 until built)", "lint": "PASS — 0 errors across the four packages; the new test file alone is warning-free", "check_changeset_presence": "PASS exit=0 — 6 source files of 4 released packages changed, 1 changeset declared", "check_changeset_no_major": "PASS exit=0 — patch only", "check_control_bytes": "PASS exit=0 — plus a manual grep -naP scan of all changed files: no control bytes", "check_spec_symbols": "PASS exit=0", "check_phantom_deps": "PASS exit=0", "check_i18n_keys": "PASS exit=0", "check_i18n_drift": "PASS exit=0 — no en value changed", "check_i18n_dead_keys": "REPORT (not a gate) exit=0 — filterBuilder.operators.exists/.notExists do NOT appear; the keys stay live because the operators are still drawn, just opt-in", "reverse_verification": "RED as predicted, direction decided before running. Ablating the two ids from OPT_IN_OPERATORS on the committed fix produced exactly 8 failures — both live-grid legs, both saved-view legs, the spine equality, the named exists/notExists pin, and two in the components opt-in test — with the live-grid leg reporting the real emission ['some_field','exists','x']. Restored via git checkout from the branch (fix was committed first): 96/96 green.", "ci": "in_progress — reported at draft-PR time per the standing contract; mergeable_state was 'unstable' at hand-back", "labels_read_back": "['package: components', 'plugin', 'tests'] — bot-applied. skip-changeset deliberately NOT applied, per dispatch." }, "deviations": [ "Touched a 4th package the card did not name: packages/app-shell/src/views/viewFilterFold.ts, COMMENT ONLY. Its note explained why exists/notExists sit in VALUELESS_FILTER_OPERATORS ('they reach this fold from the shared dropdown'), a rationale this PR invalidates. The entries stay (needsValueInput still draws them value-less for the criteria widget, and the emptyValue parity test enforces that) but the reason changed, so leaving the comment would have been actively misleading. app-shell is in the changeset accordingly.", "Added an exported LIST_VIEW_EXTRA_OPERATORS = [] to ListView and passed it explicitly rather than omitting extraOperators. Slightly unusual (an empty prop), but without it the parity test would assert about operatorsForFieldType in the abstract and a future inline extraOperators at the call site could widen the dropdown with the guard still green.", "Commit trailer: used 'Co-authored-by: Claude <noreply@anthropic.com>' to match the convention on origin/main and the dispatch's no-model-identifier rule, not the model-named trailer my harness prescribes. Flagging the conflict rather than silently picking a side.", "The dispatch named grading comment 5304385680 and dispatch comment 5304417024; the newest PM comment on the card is actually 5304422246. Read both PM comments as instructed — no content difference, noting the id mismatch only so it is not read as a missed instruction.", "One test sweep exceeded the 10-minute foreground cap and was backgrounded by the harness. Collected its exit code in-turn rather than ending the turn on it; the waiter process I armed for that was killed by its own recorded PID once the run finished." ], "followups_filed": [ "filed as #4744: ListView.convertFilterGroupToAST exempts only isEmpty/isNotEmpty from its incomplete-row drop, while the builder renders no value input for six operators — so a fresh 'Is null' row carries value:'' , is judged unfinished and is dropped (measured isNull -> [] versus isEmpty -> ['name','=',null]). Live user-visible: picking 'Is null' as the first action applies no filter at all. Separate axis from this card (value-completeness, not vocabulary) and separate defect class, so not fixed here per scope discipline; isNull/isNotNull remain offered precisely because both dialects do express them. Filed unassigned, unlabeled, with a backlink and no closing keyword. Searched first — no existing open issue (nearest are #2364 closed, #3958 a different builder finding)." ], "open_questions": [], "out_of_scope_findings": [ "filed as #4744: fresh valueless isNull/isNotNull rows are silently dropped by the live-grid filter path" ] }Generated by Claude Code