Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(types): declare QueryParams.$filter as the union it already accepts by yinlianghui · Pull Request #5999 · objectstack-ai/objectui · GitHub
Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types): declare QueryParams.$filter as the union it already accepts by yinlianghui · Pull Request #5999 · objectstack-ai/objectui · GitHub
Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types): declare QueryParams.$filter as the union it already accepts by yinlianghui · Pull Request #5999 · objectstack-ai/objectui · GitHub
Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(types): declare QueryParams.$filter as the union it already accepts by yinlianghui · Pull Request #5999 · objectstack-ai/objectui · GitHub
Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types): declare QueryParams.$filter as the union it already accepts by yinlianghui · Pull Request #5999 · objectstack-ai/objectui · GitHub
Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types): declare QueryParams.$filter as the union it already accepts by yinlianghui · Pull Request #5999 · objectstack-ai/objectui · GitHub
Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(types): declare QueryParams.$filter as the union it already accepts by yinlianghui · Pull Request #5999 · objectstack-ai/objectui · GitHub
Skip to content

fix(types): declare QueryParams.$filter as the union it already accepts - #5999

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union
Aug 24, 2026
Merged

fix(types): declare QueryParams.$filter as the union it already accepts#5999
yinlianghui merged 2 commits into
mainfrom
claude/issue-3909-queryparams-filter-union

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3909

QueryParams.$filter was declared Record<string, any> with a record-only @example, while two standing producers have fed ObjectQL AST arrays through the slot all along and the data sources accept them. The runtime was right; the declaration was narrow.

What changed

$filter now declares both shapes it accepts. The array half is bound to @objectstack/spec/data's FilterArray rather than restated locally:

$filter?: Record<string,any>|FilterArray;

Binding rather than re-declaring is the file's own stated convention (Spec-owned names are bound here, not re-declared), and it matters here specifically: a local FilterNode fork would satisfy every call site while being free to drift from the vocabulary the servers parse — the exact failure two hand-written operator lists had in #3948. The spec's FilterArray doc even names "the wire $filter face" as one of its measured producers, so this slot was already in its declared scope.

The doc comment names translateFilterToAST (@object-ui/data-objectstack) as the authoritative accepted set rather than carrying a second list to drift from, and gains array @examples.

Nothing is narrowed — measured, not assumed

Record<string, any> already accepts arrays structurally (they satisfy its string index), so the union documents shapes that were always legal rather than admitting new ones. Measured under the repo's tsc with --strict, no casts:

inputold declnew decl
{ age: { $gt: 18 } }acceptedaccepted
['status', '=', 'active']acceptedaccepted
['and', [...], [...]]acceptedaccepted
[[...], [...]] (legacy list)acceptedaccepted
[{ field, operator, value }]acceptedaccepted
42 / 'status eq active'rejectedrejected

The rejection row is a negative control: it proves the probe can go red, and that QueryParams' [key: string]: any index signature is not swallowing the slot.

Why it was worth fixing anyway

The harm was entirely on the type face and two-sided:

  1. The type blocked nothing while describing one legal shape as if it were the only one. record:related_list.add.picker.filter 全仓零读点:作者限定了 Add 选择器的候选范围,对话框照样提供该对象的全部记录 #3831 is what that cost: a Record<string, any> slot accepted a rule array, an object spread flattened it to {"0": {...}}, types stayed green, and the query filtered on a column literally named 0.
  2. A new consumer reads the type and its record-only @example, concludes the array path is illegal, and adds a tolerant conversion for it — the "widen the consumer to tolerate the producer" shape AGENTS.md #0.1 forbids.

The #3908 workaround is removed

PR #3908 wrote filter as Record<string, any> at one assignment in packages/fields/src/widgets/useRecordQuery.ts, deliberately, as debt rather than widening the shared type — its comment said so. The shared type is honest now, so the debt is paid rather than moved: hasFilter is a type predicate narrowing to the $filter slot's own type (filter is NonNullable<QueryParams['$filter']>), so the assignment needs no cast and the guard cannot drift from the declaration it guards.

The pin test was a phantom, and that is why it is an identity check

Worth flagging, because the first version of the guard was wrong in a way that reads like enforcement. Every assignment-shaped pin is vacuous as a regression guard here — measured, not assumed. Reverting the declaration to the bare record and re-running type-check left it green (exit 0): assignability cannot separate the two, since arrays satisfy Record<string, any>'s string index, so FilterArray extends QueryParams['$filter'] holds under both declarations and the old declaration is itself assignable to the new union.

Identity is the property that actually differs, so the pin asserts it:

Assert<Equal<NonNullable<QueryParams['$filter']>,Record<string,any>|FilterArray>>

Reverse-verified from a committed state, mutation confirmed on disk, mutation and measurement in one shell invocation with a restoring trap:

legresult
baseline type-checkgreen, exit 0
mutatedtype-checkred, exit 2TS2344: Type 'false' does not satisfy the constraint 'true'
mutated vitestgreen, 6/6 — the blindness reproduced

That runtime-green/compile-red split is the drift's own signature: no runtime suite could ever have caught this.

Verification

All at ab70c650:

  • pnpm --filter @object-ui/types build — exit 0
  • pnpm --filter @object-ui/types type-check — exit 0 (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields type-check — exit 0
  • pnpm exec vitest run packages/types/src/__tests__/query-params-filter-union.test.tsTests 6 passed (6)
  • Downstream sweepturbo run build --filter '...@object-ui/types' (prefix = dependents): 43 successful, 43 total. Each is a tsc of its own src/ against the new dist/*.d.ts, which is the evidence a published type change breaks no consumer.
  • eslint . --no-inline-config repo-wide: population 3626 files; my three files contribute 0 errors (37 warnings, all pre-existing no-explicit-any / react-hooks on lines untouched by this diff). The 74 error-bearing files are all outside the diff, and type-aware linting is not configured, so this change provably cannot move any untouched file's verdict.

Type-only throughout; no runtime behaviour changes.

Generated by Claude Code


Generated by Claude Code

`$filter` was declared `Record<string, any>` with a record-only `@example`,
while two standing producers — plugin-list's `buildEffectiveFilter` and
plugin-view's `ObjectView` — have fed ObjectQL AST arrays through the slot all
along, and the data sources accept them.
Nothing is narrowed: `Record<string, any>` already accepts arrays structurally,
so the union documents what was always legal. The array half is bound to
`@objectstack/spec/data`'s `FilterArray` rather than restated, and the comment
names `translateFilterToAST` as the authoritative accepted set rather than
carrying a second list to drift from.
Drops the cast objectui#3908 took at one assignment in `useRecordQuery` as
declared debt; `hasFilter` narrows to the slot's own type instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
The assignment-shaped pins were vacuous as regression guards, measured rather
than assumed: reverting the declaration to `Record<string, any>` left
`type-check` green (exit 0). Assignability cannot separate the two — arrays
satisfy the record's string index, so `FilterArray extends
QueryParams['$filter']` holds under both declarations.
Identity is the property that differs. The pin now fails on a revert AND on a
locally forked AST type replacing the spec binding.
Reverse-verified: mutated tree -> type-check exit 2 (TS2344), vitest 6/6 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B_FwXNtk.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

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.

观察:QueryParams.$filter 声明为 Record< string, any >,但仓内下沉口早已往里送 ObjectQL AST 数组

2 participants

@yinlianghui@claude