Skip to content

fix(core): refuse a bare array comparand in convertFiltersToAST - #8551

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-8530-filter-converter-array-comparand
Sep 8, 2026
Merged

fix(core): refuse a bare array comparand in convertFiltersToAST#8551
os-justin merged 2 commits into
mainfrom
claude/issue-8530-filter-converter-array-comparand

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8530

What changed

convertFiltersToAST (packages/core/src/utils/filter-converter.ts) now refuses a bare ARRAY in comparand position — { tags: ['a', 'b'] } — with a FilterOperatorError (INVALID_FILTER / 400) instead of lowering it to ['tags', '=', ['a', 'b']]. The message names the field, prints the comparand, says the value is deliberately NOT read as membership (and why), and prescribes { $in: [...] } / { $nin: [...] } / { $between: [min, max] }. It is the third member of the family this file already refuses ($regex, $not), per the ruling on the card (comment 5583351371): not lowered to in.

$in / $nin / $between members, $and / $or groups and stored ViewFilterRule values (in / between) lower exactly as before — the non-regression section of the pin file is what carries the weight (see the caricature leg below).

Why the producer, measured

Against @objectstack/spec 17.3.0 (pinned in the test): the spec's own doors pass the old node through unjudged — isFilterAST(['tags', '=', ['a', 'b']]) is true and parseFilterAST hands back { tags: ['a', 'b'] } — because assertListComparandShapes rules only on $in / $nin / $between. So the refusal arrived two layers later (driver-sql's 400 from the wire, or an empty list from the in-memory matchers) and the author learned nothing at lowering time. Contract-first (AGENTS.md #0.1): the producer says it.

No shipped producer emits the shape: every multi-value comparand under packages/*/src and apps/*/src is spelled { $in: [...] } (FilterConditionField.condToMongo, dashboard-filters.buildFilterCondition, the LookupField / PeoplePicker / RecordPickerDialog id restrictions), and no test fixture feeds a bare-array object literal to convertFiltersToAST / toFilterNode / mergeFilterNodes — ripgrep over packages/**/src, apps/**/src, examples/**/src: 0 hits; lit control (same regex without the array constraint): 32 hits.

Files

  • packages/core/src/utils/filter-converter.ts — the arm and its @throws line. The typeof value === 'object' && !Array.isArray(value) condition is kept as-is on purpose: with the arm ahead of it, an arm deleted later degrades to the honest pre-fix shape rather than to a nonsense "Unknown filter operator '0'" diagnostic (see the first ablation attempt below).
  • packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts — 15 pins: 7 refusal pins (envelope on a CAPTURED error, message quality, comparand print, empty array, placement beside siblings and inside $and / $or, sink delegation) and 8 non-regression pins on PRODUCED nodes put through isFilterAST / parseFilterAST, none of them "does not throw".
  • packages/data-objectstack/README.md — one paragraph under the operator table that documents this lowering. Two rows of that table are stale independently of this card ($nin documented as notin, $regex documented as contains); not touched here, reported to the PM.
  • .changeset/8530-filter-converter-array-comparand.md@object-ui/core: patch.

Verification

Code head 3fff2fd7e; the only later commit (e8d5804f7) rewords the changeset, and the ratchet gates were re-run on it.

  • pnpm exec vitest run --maxWorkers=2 packages/core/ packages/data-objectstack/Test Files 186 passed (186), Tests 3514 passed (3514); lock verdict VERDICT command-exit 0.
  • Targeted run of every test file that calls the sink (git grep, lit control 12 hits in the sink's own test), including fields/.../FilterConditionField.operators.test.ts, data-objectstack/src/filter-dialect-wire-7221.test.ts and filter-entry-translation.test.tsTest Files 9 passed (9), Tests 250 passed (250).
  • pnpm --filter @object-ui/core type-check (build and test programs) exit 0; pnpm --filter @object-ui/core lint 0 errors (531 pre-existing no-explicit-any warnings, none in the touched files).
  • Gates on e8d5804f7: check-changeset-presence ✅ (2 source files of 1 released package, 1 changeset declared), check-changeset-fixed ✅, check-changeset-no-major ✅, check:control-bytes ✅ (6758 files), check:doc-fences ✅. On 3fff2fd7e: check:shell-escape-residue ✅, check:unreferenced-sources OK, check:vi-mock-specifiers ✅, check:doc-example-readers OK.
  • check:readme-exports: NOT MEASURED locally. It exits 1 with "465 self-import(s) could not be judged … ./dist/index.d.ts is not on disk -- run pnpm build first" — the whole-repo unbuilt-tree precondition (packages/data-objectstack/README.md is not among its complaints, and the added paragraph carries no import or export name). CI owns the built run.
  • Narrowing declared: turbo ls --affected lists every package (core is a root dependency), and the full pnpm test is CI's four-shard run. Local scope: core and data-objectstack in full plus every sink-calling test file, with the two ripgrep controls above as the checkable reason nothing outside that scope can change verdict.

Ablation and caricature — run from the committed head, not predicted

Both legs mutate the committed file, prove the mutation on disk in both directions, run the pin file, and restore by STATE (git checkout HEAD -- path, then git diff HEAD empty and git hash-object equal to the HEAD: blob 92cd70dc3), with a trap on EXIT INT TERM using absolute paths. The pin file imports ../filter-converter relatively, so no dist/ sits in the resolution path and no rebuild is needed between mutation and observation.

  • Leg A — ablation (arm deleted → exact pre-fix shape; marker count 1 → 0, git diff --stat 40 deletions): Tests 6 failed | 9 passed (15). Red: all six refusal pins. Green: the spec-door pin (it pins the spec, not the fix) and all eight non-regression pins.
  • Leg B — caricature (the arm moved ahead of the combinator arm plus a copy on operator members, i.e. "throw on everything array-shaped"; marker 1 → 2, Array.isArray(operatorValue) 0 → 1, arm at line 254 before the combinator arm at 277): Tests 7 failed | 8 passed (15). Red: the six non-regression pins that pass through the object arm ($in, $nin, $between, members inside $or, $and / $or groups, merge with $in) plus "refuses the array wherever it sits" — the caricature refuses { $or: [...] } on field $or before reaching tags. Green under the caricature, i.e. NOT discriminating it: envelope, message quality, comparand print, empty array, sink delegation, spec-door pin, scalar equality — and the stored-ViewFilterRule in / between pin, which lowers through viewFilterRuleToNode rather than the object arm and therefore discriminates a different caricature (one placed in that function), not this one.
  • A first ablation attempt is reported as the null-ish reading it was: it deleted the arm but kept an interim typeof value === 'object' condition, so arrays fell into the operator loop and were refused as "Unknown filter operator '0'" — Tests 3 failed | 12 passed, with the envelope pin passing on a tree refusing for the wrong reason. The condition was restored and both legs re-run from the amended commit.

Related, left open

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

`{ tags: ['a', 'b'] }` used to reach the simple-equality arm and lower to
`['tags', '=', ['a', 'b']]` — an array in a scalar-equality slot that the
spec's doors pass through unjudged, driver-sql refuses with 400
INVALID_FILTER, and every in-memory matcher answers with an empty list.
The author learned nothing at lowering time.

It is now refused HERE with a FilterOperatorError (INVALID_FILTER / 400)
that names the field, prints the comparand, says it is deliberately not
read as membership, and prescribes `{ $in: [...] }` / `{ $nin: [...] }` /
`{ $between: [min, max] }` — the third member of the family `$regex` and
`$not` already belong to. `$in` / `$nin` / `$between` members, `$and` /
`$or` groups and stored ViewFilterRule values keep lowering untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
… shape

PR #8529's own header records that a document store reads the array
comparand natively; "no backend honoured" overstated it. The changeset now
says no RULED contract ever answered the shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions github-actions Bot added documentation Improvements or additions to documentation data-adapter package: core tests labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

❌ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3475.4 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-o9thIQ0R.js
Status FAIL

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.

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ❌ over its ceiling
Ceiling sensitivity (headroom) ✅ pass
Ceiling freshness (checkout vs. base branch) ✅ pass

📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 192.25KB 53.44KB
fields (index.js) 243.24KB 61.42KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.46KB 63.90KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.30KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-justin
os-justin marked this pull request as ready for review September 8, 2026 10:57
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit f391ede Sep 8, 2026
34 of 35 checks passed
@os-justin
os-justin deleted the claude/issue-8530-filter-converter-array-comparand branch September 8, 2026 11:14
os-justin pushed a commit that referenced this pull request Sep 8, 2026
…ertFiltersToAST and pin it

The README's "Supported Filter Operators" table was a hand-written mirror of
convertOperatorToAST's operatorMap and had drifted from the code three ways:
`$nin` / `$notin` documented as lowering to `notin` (the map says `nin`, and
the worked example's output was a node the server refuses); `$regex`
documented as lowering to `contains` (it has been refused with a
FilterOperatorError since PR #8512); only the non-spec `$startswith` spelling
listed. Re-deriving every row found four supported operators with no row at
all — `$notContains`, `$endsWith`, `$null`, `$exists`, the ones the code's own
"Supported operators" message enumerates — and no mention of `$and` / `$or` or
the `$not` refusal.

Every row is corrected against the file as it stands after PRs #8512, #8529
and #8551, the missing rows are added, and two small tables document the
combinators and the refusals. A new pin,
src/readme-filter-operator-table.test.ts, reconciles the tables against the
code on every run: each worked example is executed through
convertFiltersToAST and must produce exactly its documented output (or throw
the INVALID_FILTER / 400 envelope the refused table promises), each spelling
must lower to the operator its row names, and the supported table must carry
a row for every key of the operator map (read from source, aliases included)
and for every operator the unknown-operator error calls supported.

The package tsconfig names `types: ["node"]` so the pin's node:fs / node:url
reads type-check in the program that already compiles this package's tests
(measured: TypeScript 6.0.3 does not see the root @types/node from here
without it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapter documentation Improvements or additions to documentation package: core tests

Projects

None yet

2 participants