Skip to content

Commit 6696056

Browse files
fix(lint): walk page filterBy and lookup-field lookupFilters as authored filters (#19818)
Fixes #19791 Clause-②: no <sub>Rewritten short by the `domain:spec#5` seat (2026-09-23T15:28Z), which took this PR over from seat 2. The earlier body is in the edit history; the dev reports are on #19791.</sub> A page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` hold filter rules that reach the engine's `where`, but the lint's shared filter walk did not enter them. A date-range preset in either, such as `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }`, parsed, linted and published green. ## What changed - **Lint** (`@objectstack/lint` patch): `FILTER_KEYS` in `packages/lint/src/filter-walk.ts` adds `filterBy` and `lookupFilters`. `filter-preset-comparand` binds a `filterBy` rule to `interfaceConfig.source` (else the page's `object`), and a `lookupFilters` rule to the field's `reference`. A `relatedListFilter` on the same field still binds to the owning object. `filter-token-unknown` reaches both keys through the same walk. - **Migration entry `18.filter-preset-ordering-comparand-refused`** (`@objectstack/spec` patch; rulings `5793402649` and `5794625162` on #19791): the fix makes parts of it false, and they are deleted with no replacement: `surface` group (3) and the word "three" before it, the by-hand clause in `acceptanceCriteria`, and the "reached by neither" clause in `reason`. `registry.ts` is regenerated. - **Pending note `.changeset/19778-preset-entry-carriers.md`** (from #19801, not yet released): its item 3 said both keys lint green and must be swept by hand. That is false after this PR, so item 3 is deleted, "The three groups" becomes "The groups", and the Reach sub-bullet counting "nine sentences" is deleted too. ## `Check Changeset` is red on purpose This PR edits a pending changeset it did not add (the #19778 note above). `check-empty-changeset.mjs` calls this a DELIBERATE CORRECTION: the check stays red. The correction is recorded on this PR (`5796661306`) and awaits the maintainer's written confirmation. `Check Changeset` is not a required context. ## Measured (dev reports on #19791) - With the card's rule in each key: `os lint`, `os validate` and the runtime publish gate refuse it at this head; none of them refuses it at `afc3b64928`. Lit control (a component `dataSource.filter`): refused on both trees. Dark control (an ISO date): green on both trees. - New tests: 5 in `validate-preset-comparands.test.ts` and 1 in `validate-filter-tokens.test.ts`. Restoring the base source turns 5 red. Three ablations each turn their pin red: dropping the two keys, dropping the `lookupFilters` binding, and dropping the `interfaceConfig` reader. - Two at-tier reviews (`5795612549`, `5796648031`) failed sentences, not code. Rounds 4 and 5 cut them: three "the two carriers" exclusives (object-grid `defaultFilters` is also outside the walk), a lit-control claim, an unknown token called "silent" (the engine throws `FILTER_TOKEN_UNKNOWN`), and "refused only at query time". - CI at `4dda1fd0fd`: all seven required contexts pass. `Check Changeset` is red as described above; the gate comment is `5796661306`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5dba7f3 commit 6696056

9 files changed

Lines changed: 248 additions & 40 deletions

‎.changeset/19778-preset-entry-carriers.md‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,12 @@ Clause-②: no
88

99
No behaviour moves. No schema, accept set or lint rule is touched, and no export is added, removed or retyped. Every line this change edits in `registry.ts` is a string literal inside that one step-18 entry, which the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose.
1010

11-
- **The three groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell.
11+
- **The groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell.
1212
1. Slots typed `FilterConditionSchema`: `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter`, `DatasetSchema.filter`, `DatasetMeasureSchema.filter`, `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter`, `FieldSchema.relatedListFilter` and `FieldSchema.summaryOperations.filter`. A parse of the declaring schema refuses each one at the comparand's own path, and the lint rule reports each one as well.
1313
2. Filters under a key the lint walks whose declared type carries no preset check. These are `ViewFilterRuleSchema` rule arrays (a view's `filter`, a page element's `dataSource.filter`, a page component's `filter` prop) and a Mongo-shape record typed as a loose record rather than `FilterConditionSchema` (a flow `get_record` / `update_record` / `delete_record` node's `config.filter`). These parse green, and the lint rule alone refuses them.
14-
3. A page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` parse green and also lint green, because the lint's filter walk descends neither key. Neither door refuses them at publish, so the entry now tells the upgrader to sweep both by hand. Two controls back the `filterBy` reading. A malformed `filterBy` value is refused at `interfaceConfig.filterBy.0.value`, so the slot is parsed. The same rule under `interfaceConfig.filter` is refused by the lint, so the key name is what decides. `lookupFilters` takes `gt`, `gte`, `lt` and `lte` as its only ordering spellings (`greater_than`, `after`, `between`, `>` and `$gt` are refused at `lookupFilters.0.operator`), and the by-hand clause names those four.
1514
- **Two more false sentences are narrowed.**
1615
- The `replacement` called the dashboard date-filter positions "the only place any layer ever resolved" a preset name. An analytics query's `timeDimensions[].dateRange` accepts and resolves the names too.
1716
- The `reason` said equality and membership "are NOT judged". That holds for the schema door only. The lint rule refuses a preset in an equality or membership position on a field it can resolve to a declared `date` or `datetime`, while `this_quarter` on a `select` field stays green. Where the filter binds to no object, such as a widget whose `dataset` names no dataset, that arm does not fire.
1817
- **Reach.** Counted over `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs`:
1918
- The removed carrier list `page filter, component filter, rollup filter` and each of the four other removed claims read 4 before and 0 after.
20-
- Each of nine sentences unique to the corrected text reads 0 before and 4 after.
2119
- The unchanged dark control `compared false against every row: HTTP 200` reads 4 on both sides.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`filter-preset-comparand` now judges a list page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters`, consumed filter carriers the shared filter walk never entered (#19791).
6+
7+
Both carriers are rule arrays (`{ field, operator, value }`) whose values reach the engine's `where` verbatim, and neither schema carries a preset check. So `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one parsed green and linted green, then the engine refused it at query time (`INVALID_FILTER` / 400). The same rule on a component `dataSource.filter` or a view `filter` was already refused. `filterBy` and `lookupFilters` join `FILTER_KEYS`, so `os lint`, `os validate` and the runtime publish gate (for `page` and `object` writes) now refuse it where it is written. Each finding carries its path (`pages[0].interfaceConfig.filterBy[0].value`, `objects[2].fields.account.lookupFilters[0].value`).
8+
9+
- **Which object a condition addresses.** The field-typed arm, which refuses a preset under equality or membership on a `date` / `datetime` field, binds `filterBy` to `interfaceConfig.source`. Without a `source` it falls back to the page's `object`. It binds `lookupFilters` to the field's `reference` and never to the object that owns the field, because the picker queries the referenced object. A `relatedListFilter` on the same field still binds to the owner.
10+
- **`filter-token-unknown` reaches the same two carriers.** An unresolvable placeholder such as `{current_user}` in `filterBy` or `lookupFilters` is now reported, as it already is in a view's `filter`. `{current_user_id}` and the date macros stay clean.
11+
- **What you do:** in a `filterBy` or `lookupFilters` rule, replace a preset name with the `{date-macro}` window the message names (`{ operator: 'gte', value: '{30_days_ago}' }`) or with an ISO date.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` drops, from its `surface` and its `reason`, the text that said a page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` are refused by neither door at publish, and drops, from its `acceptanceCriteria`, the by-hand search it prescribed for those two keys. The `@objectstack/lint` `filter-preset-comparand` rule now walks both keys, so `os lint`, `os validate` and the runtime publish gate (on `page` and `object` writes) refuse `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one, while the schema parse still accepts it.
6+
7+
Clause-②: no

‎packages/lint/src/filter-walk.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,27 @@ type AnyRec = Record<string, unknown>;
5757
/**
5858
* Keys whose subtree is a filter. The one place a filter is authored.
5959
*
60-
* `relatedListFilter` (#8704) is the one member that does not spell the key
61-
* `filter`: it sits flat on a FIELD beside its `relatedList`/`relatedListTitle`/
60+
* `relatedListFilter` (#8704) sits flat on a FIELD beside its `relatedList`/`relatedListTitle`/
6261
* `relatedListColumns` family, so the family naming wins over the filter-key
6362
* convention. It carries a canonical Query-DSL `FilterCondition` (the schema
6463
* door already judges it at parse), and listing it here is what extends the
6564
* three walking rules — tokens, empty combinators, preset comparands — to the
6665
* new position instead of leaving a per-rule hole.
66+
*
67+
* `filterBy` and `lookupFilters` (#19791) are consumed RULE-ARRAY
68+
* carriers that do not spell the key `filter`: a list page's always-on
69+
* base filter (`interfaceConfig.filterBy`, `z.array(ViewFilterRuleSchema)`,
70+
* which the console spreads into the list query beside the view's own
71+
* `filter`) and a lookup field's picker filter (`lookupFilters`, lowered by the
72+
* console to a Mongo `$filter` on the REFERENCED object). Both values reach
73+
* the engine's `where` verbatim. Their schemas carry no preset check, so
74+
* before this entry an ordering preset in either parsed green, linted green.
75+
* The binding half — which object a condition on each carrier
76+
* addresses — is `validate-preset-comparands.ts`'s, not this walk's.
6777
*/
68-
export const FILTER_KEYS: ReadonlySet<string> = new Set(['filter', 'filters', 'runtimeFilter', 'relatedListFilter']);
78+
export const FILTER_KEYS: ReadonlySet<string> = new Set([
79+
'filter', 'filters', 'runtimeFilter', 'relatedListFilter', 'filterBy', 'lookupFilters',
80+
]);
6981

7082
/** One stack collection a caller wants walked. */
7183
export interface FilterSurface {

‎packages/lint/src/validate-filter-tokens.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,43 @@ describe('validateFilterTokens', () => {
180180
).toEqual([]);
181181
});
182182

183+
// [#19791] A list page's `interfaceConfig.filterBy` and a lookup field's
184+
// `lookupFilters` reach the engine's `where` verbatim, where the same two
185+
// placeholder vocabularies resolve.
186+
it('reaches a page filterBy and a lookup field lookupFilters', () => {
187+
const findings = validateFilterTokens({
188+
objects: [{
189+
name: 'invoice',
190+
fields: {
191+
account: {
192+
type: 'lookup',
193+
reference: 'account',
194+
lookupFilters: [
195+
{ field: 'owner', operator: 'eq', value: '{current_user}' },
196+
{ field: 'owner', operator: 'ne', value: '{current_user_id}' },
197+
],
198+
},
199+
},
200+
}],
201+
pages: [{
202+
name: 'deals',
203+
type: 'list',
204+
interfaceConfig: {
205+
source: 'deal',
206+
filterBy: [
207+
{ field: 'owner', operator: 'equals', value: '{user_id}' },
208+
{ field: 'created_at', operator: 'greater_than', value: '{30_days_ago}' },
209+
],
210+
},
211+
}],
212+
});
213+
expect(findings.map((f) => f.path).sort()).toEqual([
214+
'objects[0].fields.account.lookupFilters[0].value',
215+
'pages[0].interfaceConfig.filterBy[0].value',
216+
]);
217+
for (const f of findings) expect(f.rule).toBe(FILTER_TOKEN_UNKNOWN);
218+
});
219+
183220
it('survives a cyclic metadata graph', () => {
184221
const dash: Record<string, unknown> = { name: 'd', widgets: [] };
185222
dash.self = dash;

‎packages/lint/src/validate-preset-comparands.test.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,3 +672,116 @@ describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / members
672672
]);
673673
});
674674
});
675+
676+
// ── [#19791] consumed rule-array carriers the walk never entered ────
677+
//
678+
// A list page's `interfaceConfig.filterBy` and a lookup field's
679+
// `lookupFilters` both reach the engine's `where` verbatim, and neither schema
680+
// carries a preset check. Before `FILTER_KEYS` named them, the card's rule
681+
// `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` parsed
682+
// green AND linted green on both, while the identical rule on a component
683+
// `dataSource.filter` was refused.
684+
describe('validatePresetComparands — page filterBy and lookup-field lookupFilters (#19791)', () => {
685+
const card = { field: 'close_date', operator: 'gt', value: 'last_30_days' };
686+
// `close_date` is a DATE on `crm_deal` and a SELECT on `crm_region`, whose
687+
// option value collides with a preset name — the pair that tells a binding
688+
// to the right object from a binding to the wrong one.
689+
const carrierObjects = [
690+
{ name: 'crm_deal', fields: { close_date: { type: 'date' }, name: { type: 'text' } } },
691+
{ name: 'crm_region', fields: { close_date: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] } } },
692+
];
693+
const listPage = (interfaceConfig: Record<string, unknown>, over: Record<string, unknown> = {}) => ({
694+
objects: carrierObjects,
695+
pages: [{ name: 'deals', type: 'list', interfaceConfig, ...over }],
696+
});
697+
const lookupHolder = (account: Record<string, unknown>, owner: Record<string, unknown> = {}) => ({
698+
objects: [
699+
...carrierObjects,
700+
{ name: 'crm_invoice', fields: { ...owner, account: { type: 'lookup', ...account } } },
701+
],
702+
});
703+
704+
it("refuses the card's rule in a page's filterBy, beside the lit control on the same page", () => {
705+
const findings = validatePresetComparands({
706+
objects: carrierObjects,
707+
pages: [{
708+
name: 'deals', type: 'list',
709+
interfaceConfig: { source: 'crm_deal', filterBy: [card, { field: 'close_date', operator: 'between', value: ['this_week', '2026-01-01'] }] },
710+
components: [{ type: 'list', dataSource: { object: 'crm_deal', filter: [card] } }],
711+
}],
712+
});
713+
expect(findings.map((f) => f.path).sort()).toEqual([
714+
'pages[0].components[0].dataSource.filter[0].value',
715+
'pages[0].interfaceConfig.filterBy[0].value',
716+
'pages[0].interfaceConfig.filterBy[1].value[0]',
717+
]);
718+
const onFilterBy = findings.find((f) => f.path === 'pages[0].interfaceConfig.filterBy[0].value')!;
719+
expect(onFilterBy.severity).toBe('error');
720+
expect(onFilterBy.rule).toBe(FILTER_PRESET_COMPARAND);
721+
expect(onFilterBy.where).toBe('page "deals"');
722+
expect(onFilterBy.message).toContain('"last_30_days" is a dashboard date-range PRESET name');
723+
});
724+
725+
it("refuses the card's rule in a lookup field's lookupFilters, map-form and array-form fields alike", () => {
726+
expect(validatePresetComparands(lookupHolder({ reference: 'crm_deal', lookupFilters: [card] }))
727+
.map((f) => f.path)).toEqual(['objects[2].fields.account.lookupFilters[0].value']);
728+
expect(validatePresetComparands({
729+
objects: [{ name: 'crm_invoice', fields: [{ name: 'account', type: 'lookup', reference: 'crm_deal', lookupFilters: [card] }] }],
730+
}).map((f) => f.path)).toEqual(['objects[0].fields[0].lookupFilters[0].value']);
731+
});
732+
733+
it('judges equality on filterBy against the object interfaceConfig.source names, falling back to the page object', () => {
734+
const eq = { field: 'close_date', operator: 'equals', value: 'this_quarter' };
735+
// `source` is the date object: refused.
736+
expect(validatePresetComparands(listPage({ source: 'crm_deal', filterBy: [eq] })).map((f) => f.path))
737+
.toEqual(['pages[0].interfaceConfig.filterBy[0].value']);
738+
// `source` is the select object while the page's own `object` is the date
739+
// one: the list queries `source`, so the equality is the picklist case.
740+
expect(validatePresetComparands(listPage({ source: 'crm_region', filterBy: [eq] }, { object: 'crm_deal' }))).toEqual([]);
741+
// No `source`: the page's `object`, as validate-page-field-bindings reads it.
742+
expect(validatePresetComparands(listPage({ filterBy: [eq] }, { object: 'crm_deal' })).map((f) => f.path))
743+
.toEqual(['pages[0].interfaceConfig.filterBy[0].value']);
744+
});
745+
746+
it("judges equality on lookupFilters against the field's reference, never the owning object", () => {
747+
const eq = { field: 'close_date', operator: 'eq', value: 'this_quarter' };
748+
// The referenced object declares `close_date` as a date: refused.
749+
expect(validatePresetComparands(lookupHolder({ reference: 'crm_deal', lookupFilters: [eq] })).map((f) => f.path))
750+
.toEqual(['objects[2].fields.account.lookupFilters[0].value']);
751+
// The OWNING object declares a date `close_date`, the referenced one a
752+
// select: binding to the owner would be the #16106 B1 false refusal.
753+
const owner = { close_date: { type: 'date' } };
754+
expect(validatePresetComparands(lookupHolder({ reference: 'crm_region', lookupFilters: [eq] }, owner))).toEqual([]);
755+
// No `reference` to follow: unjudged by arm 2, never the owner.
756+
expect(validatePresetComparands(lookupHolder({ lookupFilters: [eq] }, owner))).toEqual([]);
757+
// A `relatedListFilter` on the SAME field keeps binding to the owner,
758+
// whose rows it filters — one field, two filters, two objects.
759+
const both = validatePresetComparands(lookupHolder(
760+
{ reference: 'crm_region', lookupFilters: [eq], relatedListFilter: { close_date: 'this_quarter' } },
761+
owner,
762+
));
763+
expect(both.map((f) => f.path)).toEqual(['objects[2].fields.account.relatedListFilter.close_date']);
764+
});
765+
766+
it('stays quiet on every legal comparand in both carriers', () => {
767+
const legal = [
768+
{ field: 'close_date', operator: 'gt', value: '{30_days_ago}' },
769+
{ field: 'close_date', operator: 'gte', value: '2026-01-15' },
770+
{ field: 'close_date', operator: 'between', value: ['{week_start}', '{week_end}'] },
771+
{ field: 'name', operator: 'equals', value: 'last_30_days' },
772+
];
773+
// The lookup picker's own flat operator vocabulary (`FieldSchema.lookupFilters`).
774+
const legalLookup = [
775+
{ field: 'close_date', operator: 'gt', value: '{30_days_ago}' },
776+
{ field: 'close_date', operator: 'gte', value: '2026-01-15' },
777+
{ field: 'name', operator: 'eq', value: 'last_30_days' },
778+
];
779+
expect(validatePresetComparands({
780+
objects: [
781+
...carrierObjects,
782+
{ name: 'crm_invoice', fields: { account: { type: 'lookup', reference: 'crm_deal', lookupFilters: legalLookup } } },
783+
],
784+
pages: [{ name: 'deals', type: 'list', interfaceConfig: { source: 'crm_deal', filterBy: legal } }],
785+
})).toEqual([]);
786+
});
787+
});

0 commit comments

Comments
 (0)